├── .stylelintrc.mjs ├── prettier.config.mjs ├── addon-main.cjs ├── .template-lintrc.cjs ├── .stylelintignore ├── demo ├── routes │ └── index.js ├── router.js ├── styles │ └── app.css ├── config.js ├── app.js └── templates │ ├── application.gjs │ ├── native.gjs │ └── modifier.gjs ├── .gitignore ├── src ├── utils │ └── focus.js └── modifiers │ └── auto-focus.js ├── .env.development ├── index.html ├── .editorconfig ├── .prettierignore ├── config └── ember-cli-update.json ├── babel.publish.config.cjs ├── CONTRIBUTING.md ├── vite.config.mjs ├── testem.cjs ├── tests ├── index.html ├── test-helper.js └── integration │ └── auto-focus-test.gjs ├── tsconfig.publish.json ├── LICENSE.md ├── babel.config.cjs ├── .github └── workflows │ └── ci.yml ├── .try.mjs ├── rollup.config.mjs ├── CHANGELOG.md ├── eslint.config.mjs ├── README.md └── package.json /.stylelintrc.mjs: -------------------------------------------------------------------------------- 1 | import zestia from '@zestia/stylelint-config'; 2 | 3 | export default zestia; 4 | -------------------------------------------------------------------------------- /prettier.config.mjs: -------------------------------------------------------------------------------- 1 | import zestia from '@zestia/prettier-config'; 2 | 3 | export default zestia; 4 | -------------------------------------------------------------------------------- /addon-main.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { addonV1Shim } = require('@embroider/addon-shim'); 4 | module.exports = addonV1Shim(__dirname); 5 | -------------------------------------------------------------------------------- /.template-lintrc.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | plugins: ['@zestia/template-lint-config'], 5 | extends: 'zestia:recommended' 6 | }; 7 | -------------------------------------------------------------------------------- /.stylelintignore: -------------------------------------------------------------------------------- 1 | # unconventional files 2 | /blueprints/*/files/ 3 | 4 | # compiled output 5 | /dist/ 6 | /dist-tests/ 7 | 8 | # addons 9 | /.node_modules.ember-try/ 10 | -------------------------------------------------------------------------------- /demo/routes/index.js: -------------------------------------------------------------------------------- 1 | import Route from '@ember/routing/route'; 2 | import { service } from '@ember/service'; 3 | 4 | export default class IndexRoute extends Route { 5 | @service router; 6 | 7 | redirect() { 8 | return this.router.transitionTo('modifier'); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | dist/ 3 | dist-tests/ 4 | declarations/ 5 | 6 | # from scenarios 7 | tmp/ 8 | config/optional-features.json 9 | ember-cli-build.js 10 | 11 | # npm/pnpm/yarn pack output 12 | *.tgz 13 | 14 | # deps & caches 15 | node_modules/ 16 | .eslintcache 17 | .prettiercache 18 | -------------------------------------------------------------------------------- /src/utils/focus.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable ember/no-runloop */ 2 | 3 | import { next } from '@ember/runloop'; 4 | 5 | export default function focus(element, options) { 6 | element.dataset.programmaticallyFocused = 'true'; 7 | element.focus(options); 8 | next(() => delete element.dataset.programmaticallyFocused); 9 | } 10 | -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | # This file is committed to git and should not contain any secrets. 2 | # 3 | # Vite recommends using .env.local or .env.[mode].local if you need to manage secrets 4 | # SEE: https://vite.dev/guide/env-and-mode.html#env-files for more information. 5 | 6 | 7 | # Default NODE_ENV with vite build --mode=test is production 8 | NODE_ENV=development 9 | -------------------------------------------------------------------------------- /demo/router.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable array-callback-return */ 2 | 3 | import EmberRouter from '@ember/routing/router'; 4 | import config from './config.js'; 5 | 6 | export default class Router extends EmberRouter { 7 | location = config.locationType; 8 | rootURL = config.rootURL; 9 | } 10 | 11 | Router.map(function () { 12 | this.route('native'); 13 | this.route('modifier'); 14 | }); 15 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | @zestia/ember-auto-focus 6 | 7 | 8 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /demo/styles/app.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --red: red; 3 | --green: green; 4 | } 5 | 6 | /* stylelint-disable-next-line */ 7 | a { 8 | display: inline-block; 9 | } 10 | 11 | /* stylelint-disable-next-line */ 12 | a.active { 13 | font-weight: bold; 14 | } 15 | 16 | .foo { 17 | outline: none; 18 | border: 2px solid var(--red); 19 | width: 50px; 20 | height: 50px; 21 | } 22 | 23 | .foo:focus { 24 | border-color: var(--green); 25 | } 26 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [*.hbs] 16 | insert_final_newline = false 17 | 18 | [*.{diff,md}] 19 | trim_trailing_whitespace = false 20 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # unconventional js 2 | /blueprints/*/files/ 3 | /vendor/ 4 | 5 | # compiled output 6 | /dist/ 7 | /tmp/ 8 | 9 | # dependencies 10 | /bower_components/ 11 | /node_modules/ 12 | 13 | # misc 14 | /coverage/ 15 | !.* 16 | .eslintcache 17 | .lint-todo/ 18 | 19 | # ember-try 20 | /.node_modules.ember-try/ 21 | /bower.json.ember-try 22 | /npm-shrinkwrap.json.ember-try 23 | /package.json.ember-try 24 | /package-lock.json.ember-try 25 | /yarn.lock.ember-try 26 | -------------------------------------------------------------------------------- /config/ember-cli-update.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaVersion": "1.0.0", 3 | "projectName": "@zestia/ember-auto-focus", 4 | "packages": [ 5 | { 6 | "name": "@ember/addon-blueprint", 7 | "version": "0.9.0", 8 | "blueprints": [ 9 | { 10 | "name": "@ember/addon-blueprint", 11 | "isBaseBlueprint": true, 12 | "options": [ 13 | "--ci-provider=github", 14 | "--npm" 15 | ] 16 | } 17 | ] 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /demo/config.js: -------------------------------------------------------------------------------- 1 | const ENV = { 2 | modulePrefix: 'demo', 3 | environment: import.meta.env.DEV ? 'development' : 'production', 4 | rootURL: '/', 5 | locationType: 'history', 6 | EmberENV: { 7 | EXTEND_PROTOTYPES: false, 8 | FEATURES: { 9 | // Here you can enable experimental features on an ember canary build 10 | // e.g. EMBER_NATIVE_DECORATOR_SUPPORT: true 11 | } 12 | }, 13 | APP: { 14 | // Here you can pass flags/options to your application instance 15 | // when it is created 16 | } 17 | }; 18 | 19 | export default ENV; 20 | -------------------------------------------------------------------------------- /babel.publish.config.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * This babel.config is only used for publishing. 5 | * 6 | * For local dev experience, see the babel.config 7 | */ 8 | module.exports = { 9 | plugins: [ 10 | [ 11 | 'babel-plugin-ember-template-compilation', 12 | { 13 | targetFormat: 'hbs', 14 | transforms: [] 15 | } 16 | ], 17 | [ 18 | 'module:decorator-transforms', 19 | { 20 | runtime: { 21 | import: 'decorator-transforms/runtime-esm' 22 | } 23 | } 24 | ] 25 | ], 26 | 27 | generatorOpts: { 28 | compact: false 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How To Contribute 2 | 3 | ## Installation 4 | 5 | - `git clone ` 6 | - `cd ember-auto-focus` 7 | - `npm install` 8 | 9 | ## Linting 10 | 11 | - `npm run lint` 12 | - `npm run lint:fix` 13 | 14 | ## Building the addon 15 | 16 | - `npm build` 17 | 18 | ## Running tests 19 | 20 | - `npm run test` – Runs the test suite on the current Ember version 21 | - `npm run test:watch` – Runs the test suite in "watch mode" 22 | 23 | ## Running the test application 24 | 25 | - `npm run start` 26 | - Visit the test application at [http://localhost:4200](http://localhost:4200). 27 | 28 | For more information on using ember-cli, visit [https://cli.emberjs.com/release/](https://cli.emberjs.com/release/). 29 | -------------------------------------------------------------------------------- /vite.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import { extensions, ember, classicEmberSupport } from '@embroider/vite'; 3 | import { babel } from '@rollup/plugin-babel'; 4 | 5 | // For scenario testing 6 | const isCompat = Boolean(process.env.ENABLE_COMPAT_BUILD); 7 | 8 | export default defineConfig({ 9 | resolve: { 10 | alias: [ 11 | { 12 | find: '@zestia/ember-auto-focus', 13 | replacement: `${__dirname}/src` 14 | } 15 | ] 16 | }, 17 | plugins: [ 18 | ...(isCompat ? [classicEmberSupport()] : []), 19 | ember(), 20 | babel({ 21 | babelHelpers: 'inline', 22 | extensions 23 | }) 24 | ], 25 | build: { 26 | rollupOptions: { 27 | input: { 28 | tests: 'tests/index.html' 29 | } 30 | } 31 | } 32 | }); 33 | -------------------------------------------------------------------------------- /testem.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | if (typeof module !== 'undefined') { 4 | module.exports = { 5 | test_page: 'tests/index.html?hidepassed', 6 | cwd: 'dist-tests', 7 | disable_watching: true, 8 | launch_in_ci: ['Chrome'], 9 | launch_in_dev: ['Chrome'], 10 | browser_start_timeout: 120, 11 | browser_args: { 12 | Chrome: { 13 | ci: [ 14 | // --no-sandbox is needed when running Chrome inside a container 15 | process.env.CI ? '--no-sandbox' : null, 16 | '--headless=new', 17 | '--disable-dev-shm-usage', 18 | '--disable-software-rasterizer', 19 | '--mute-audio', 20 | '--remote-debugging-port=0', 21 | '--window-size=1440,900' 22 | ].filter(Boolean) 23 | } 24 | } 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /demo/app.js: -------------------------------------------------------------------------------- 1 | import Application from '@ember/application'; 2 | import Resolver from 'ember-resolver'; 3 | import config from './config.js'; 4 | import * as Router from './router.js'; 5 | import * as ApplicationTemplate from './templates/application.gjs'; 6 | import * as NativeTemplate from './templates/native.gjs'; 7 | import * as ModifierTemplate from './templates/modifier.gjs'; 8 | import * as IndexRoute from './routes/index.js'; 9 | 10 | export default class App extends Application { 11 | modulePrefix = config.modulePrefix; 12 | Resolver = Resolver.withModules({ 13 | 'demo/router': Router, 14 | 'demo/templates/application': ApplicationTemplate, 15 | 'demo/templates/native': NativeTemplate, 16 | 'demo/templates/modifier': ModifierTemplate, 17 | 'demo/routes/index': IndexRoute 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /tests/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | @zestia/ember-auto-focus Tests 6 | 7 | 8 | 9 | 10 |
11 |
12 |
13 |
14 |
15 |
16 | 17 | 18 | 21 | 22 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /demo/templates/application.gjs: -------------------------------------------------------------------------------- 1 | import Route from 'ember-route-template'; 2 | import { LinkTo } from '@ember/routing'; 3 | import '../styles/app.css'; 4 | 5 | export default Route( 6 | 33 | ); 34 | -------------------------------------------------------------------------------- /tsconfig.publish.json: -------------------------------------------------------------------------------- 1 | /** 2 | * This tsconfig is only used for publishing. 3 | * 4 | * For local dev experience, see the tsconfig.json 5 | */ 6 | { 7 | "extends": "@ember/library-tsconfig", 8 | "include": ["./src/**/*", "./unpublished-development-types/**/*"], 9 | "glint": { 10 | "environment": ["ember-loose", "ember-template-imports"] 11 | }, 12 | "compilerOptions": { 13 | "allowJs": true, 14 | "declarationDir": "declarations", 15 | 16 | /** 17 | https://www.typescriptlang.org/tsconfig#rootDir 18 | "Default: The longest common path of all non-declaration input files." 19 | 20 | Because we want our declarations' structure to match our rollup output, 21 | we need this "rootDir" to match the "srcDir" in the rollup.config.mjs. 22 | 23 | This way, we can have simpler `package.json#exports` that matches 24 | imports to files on disk 25 | */ 26 | "rootDir": "./src", 27 | 28 | "types": ["ember-source/types"] 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/modifiers/auto-focus.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable ember/no-runloop */ 2 | 3 | import Modifier from 'ember-modifier'; 4 | import focus from '@zestia/ember-auto-focus/utils/focus'; 5 | import { scheduleOnce } from '@ember/runloop'; 6 | 7 | export default class AutoFocusModifier extends Modifier { 8 | didSetup = false; 9 | 10 | modify(element, positional, named) { 11 | if (this.didSetup) { 12 | return; 13 | } 14 | 15 | this.didSetup = true; 16 | 17 | const { disabled } = named; 18 | 19 | if (disabled) { 20 | return; 21 | } 22 | 23 | const [selector] = positional; 24 | 25 | if (selector) { 26 | element = element.querySelector(selector); 27 | } 28 | 29 | if (!element) { 30 | return; 31 | } 32 | 33 | scheduleOnce('afterRender', this, afterRender, element, named); 34 | } 35 | } 36 | 37 | function afterRender(element, options) { 38 | if (element.contains(document.activeElement)) { 39 | return; 40 | } 41 | 42 | focus(element, options); 43 | } 44 | -------------------------------------------------------------------------------- /tests/test-helper.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable array-callback-return */ 2 | 3 | import EmberApp from '@ember/application'; 4 | import Resolver from 'ember-resolver'; 5 | import EmberRouter from '@ember/routing/router'; 6 | import * as QUnit from 'qunit'; 7 | import { setApplication } from '@ember/test-helpers'; 8 | import { setup } from 'qunit-dom'; 9 | import { start as qunitStart, setupEmberOnerrorValidation } from 'ember-qunit'; 10 | 11 | class Router extends EmberRouter { 12 | location = 'none'; 13 | rootURL = '/'; 14 | } 15 | 16 | class TestApp extends EmberApp { 17 | modulePrefix = 'test-app'; 18 | Resolver = Resolver.withModules({ 19 | 'test-app/router': { default: Router } 20 | // add any custom services here 21 | }); 22 | } 23 | 24 | Router.map(function () {}); 25 | 26 | export function start() { 27 | setApplication( 28 | TestApp.create({ 29 | autoboot: false, 30 | rootElement: '#ember-testing' 31 | }) 32 | ); 33 | setup(QUnit.assert); 34 | setupEmberOnerrorValidation(); 35 | qunitStart(); 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 4 | 5 | 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: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | 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. 10 | -------------------------------------------------------------------------------- /babel.config.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * This babel.config is not used for publishing. 5 | * It's only for the local editing experience 6 | * (and linting) 7 | */ 8 | const { buildMacros } = require('@embroider/macros/babel'); 9 | 10 | const { 11 | babelCompatSupport, 12 | templateCompatSupport 13 | } = require('@embroider/compat/babel'); 14 | 15 | const macros = buildMacros(); 16 | 17 | // For scenario testing 18 | const isCompat = Boolean(process.env.ENABLE_COMPAT_BUILD); 19 | 20 | module.exports = { 21 | plugins: [ 22 | [ 23 | 'babel-plugin-ember-template-compilation', 24 | { 25 | transforms: [ 26 | ...(isCompat ? templateCompatSupport() : macros.templateMacros) 27 | ] 28 | } 29 | ], 30 | [ 31 | 'module:decorator-transforms', 32 | { 33 | runtime: { 34 | import: require.resolve('decorator-transforms/runtime-esm') 35 | } 36 | } 37 | ], 38 | ...(isCompat ? babelCompatSupport() : macros.babelMacros) 39 | ], 40 | 41 | generatorOpts: { 42 | compact: false 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /demo/templates/native.gjs: -------------------------------------------------------------------------------- 1 | import Route from 'ember-route-template'; 2 | import { on } from '@ember/modifier'; 3 | import Component from '@glimmer/component'; 4 | import { tracked } from '@glimmer/tracking'; 5 | 6 | class NativeRoute extends Component { 7 | @tracked shouldShowInput; 8 | 9 | showInput = () => (this.shouldShowInput = true); 10 | hideInput = () => (this.shouldShowInput = false); 11 | 12 | 39 | } 40 | 41 | export default Route(NativeRoute); 42 | -------------------------------------------------------------------------------- /demo/templates/modifier.gjs: -------------------------------------------------------------------------------- 1 | import Route from 'ember-route-template'; 2 | import { on } from '@ember/modifier'; 3 | import Component from '@glimmer/component'; 4 | import { tracked } from '@glimmer/tracking'; 5 | import autoFocus from '@zestia/ember-auto-focus/modifiers/auto-focus'; 6 | 7 | class ModifierRoute extends Component { 8 | @tracked shouldShowInput; 9 | 10 | showInput = () => (this.shouldShowInput = true); 11 | hideInput = () => (this.shouldShowInput = false); 12 | 13 | 42 | } 43 | 44 | export default Route(ModifierRoute); 45 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | pull_request: {} 9 | 10 | concurrency: 11 | group: ci-${{ github.head_ref || github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | test: 16 | name: 'Tests' 17 | runs-on: ubuntu-latest 18 | timeout-minutes: 10 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Install Node 23 | uses: actions/setup-node@v3 24 | with: 25 | node-version: 18 26 | cache: npm 27 | - name: Install Dependencies 28 | run: npm ci 29 | - name: Lint 30 | run: npm run lint 31 | - name: Run Tests 32 | run: npm run test:ember 33 | 34 | floating: 35 | name: 'Floating Dependencies' 36 | runs-on: ubuntu-latest 37 | timeout-minutes: 10 38 | 39 | steps: 40 | - uses: actions/checkout@v3 41 | - uses: actions/setup-node@v3 42 | with: 43 | node-version: 18 44 | cache: npm 45 | - name: Install Dependencies 46 | run: npm install --no-shrinkwrap 47 | - name: Run Tests 48 | run: npm run test:ember 49 | 50 | try-scenarios: 51 | name: ${{ matrix.try-scenario }} 52 | runs-on: ubuntu-latest 53 | needs: 'test' 54 | timeout-minutes: 10 55 | 56 | strategy: 57 | fail-fast: false 58 | matrix: 59 | try-scenario: 60 | - ember-lts-4.12 61 | - ember-lts-5.4 62 | - ember-release 63 | - ember-beta 64 | - ember-canary 65 | - embroider-safe 66 | - embroider-optimized 67 | 68 | steps: 69 | - uses: actions/checkout@v3 70 | - name: Install Node 71 | uses: actions/setup-node@v3 72 | with: 73 | node-version: 18 74 | cache: npm 75 | - name: Install Dependencies 76 | run: npm ci 77 | - name: Run Tests 78 | run: ./node_modules/.bin/ember try:one ${{ matrix.try-scenario }} 79 | -------------------------------------------------------------------------------- /.try.mjs: -------------------------------------------------------------------------------- 1 | // When building your addon for older Ember versions you need to have the required files 2 | const compatFiles = { 3 | 'ember-cli-build.js': `const EmberApp = require('ember-cli/lib/broccoli/ember-app'); 4 | const { compatBuild } = require('@embroider/compat'); 5 | module.exports = async function (defaults) { 6 | const { buildOnce } = await import('@embroider/vite'); 7 | let app = new EmberApp(defaults); 8 | return compatBuild(app, buildOnce); 9 | };`, 10 | 'config/optional-features.json': JSON.stringify({ 11 | 'application-template-wrapper': false, 12 | 'default-async-observers': true, 13 | 'jquery-integration': false, 14 | 'template-only-glimmer-components': true, 15 | 'no-implicit-route-model': true 16 | }) 17 | }; 18 | 19 | const compatDeps = { 20 | '@embroider/compat': '^4.0.3', 21 | 'ember-cli': '^5.12.0', 22 | 'ember-auto-import': '^2.10.0', 23 | '@ember/optional-features': '^2.2.0' 24 | }; 25 | 26 | export default { 27 | scenarios: [ 28 | { 29 | name: 'ember-lts-5.8', 30 | npm: { 31 | devDependencies: { 32 | 'ember-source': '~5.8.0', 33 | ...compatDeps 34 | } 35 | }, 36 | env: { 37 | ENABLE_COMPAT_BUILD: true 38 | }, 39 | files: compatFiles 40 | }, 41 | { 42 | name: 'ember-lts-5.12', 43 | npm: { 44 | devDependencies: { 45 | 'ember-source': '~5.12.0', 46 | ...compatDeps 47 | } 48 | }, 49 | env: { 50 | ENABLE_COMPAT_BUILD: true 51 | }, 52 | files: compatFiles 53 | }, 54 | { 55 | name: `ember-lts-6.4`, 56 | npm: { 57 | devDependencies: { 58 | 'ember-source': `npm:ember-source@~6.4.0` 59 | } 60 | } 61 | }, 62 | { 63 | name: `ember-latest`, 64 | npm: { 65 | devDependencies: { 66 | 'ember-source': `npm:ember-source@latest` 67 | } 68 | } 69 | }, 70 | { 71 | name: `ember-beta`, 72 | npm: { 73 | devDependencies: { 74 | 'ember-source': `npm:ember-source@beta` 75 | } 76 | } 77 | }, 78 | { 79 | name: `ember-alpha`, 80 | npm: { 81 | devDependencies: { 82 | 'ember-source': `npm:ember-source@alpha` 83 | } 84 | } 85 | } 86 | ] 87 | }; 88 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import { babel } from '@rollup/plugin-babel'; 2 | import { Addon } from '@embroider/addon-dev/rollup'; 3 | import { fileURLToPath } from 'node:url'; 4 | import { resolve, dirname } from 'node:path'; 5 | 6 | const addon = new Addon({ 7 | srcDir: 'src', 8 | destDir: 'dist' 9 | }); 10 | 11 | const rootDirectory = dirname(fileURLToPath(import.meta.url)); 12 | const babelConfig = resolve(rootDirectory, './babel.publish.config.cjs'); 13 | 14 | export default { 15 | // This provides defaults that work well alongside `publicEntrypoints` below. 16 | // You can augment this if you need to. 17 | output: addon.output(), 18 | 19 | plugins: [ 20 | // These are the modules that users should be able to import from your 21 | // addon. Anything not listed here may get optimized away. 22 | // By default all your JavaScript modules (**/*.js) will be importable. 23 | // But you are encouraged to tweak this to only cover the modules that make 24 | // up your addon's public API. Also make sure your package.json#exports 25 | // is aligned to the config here. 26 | // See https://github.com/embroider-build/embroider/blob/main/docs/v2-faq.md#how-can-i-define-the-public-exports-of-my-addon 27 | addon.publicEntrypoints(['**/*.js', 'index.js']), 28 | 29 | // These are the modules that should get reexported into the traditional 30 | // "app" tree. Things in here should also be in publicEntrypoints above, but 31 | // not everything in publicEntrypoints necessarily needs to go here. 32 | addon.appReexports([ 33 | 'components/**/*.js', 34 | 'helpers/**/*.js', 35 | 'modifiers/**/*.js', 36 | 'services/**/*.js' 37 | ]), 38 | 39 | // Follow the V2 Addon rules about dependencies. Your code can import from 40 | // `dependencies` and `peerDependencies` as well as standard Ember-provided 41 | // package names. 42 | addon.dependencies(), 43 | 44 | // This babel config should *not* apply presets or compile away ES modules. 45 | // It exists only to provide development niceties for you, like automatic 46 | // template colocation. 47 | // 48 | // By default, this will load the actual babel config from the file 49 | // babel.config.json. 50 | babel({ 51 | extensions: ['.js', '.gjs'], 52 | babelHelpers: 'bundled', 53 | configFile: babelConfig 54 | }), 55 | 56 | // Ensure that standalone .hbs files are properly integrated as Javascript. 57 | addon.hbs(), 58 | 59 | // Ensure that .gjs files are properly integrated as Javascript 60 | addon.gjs(), 61 | 62 | // addons are allowed to contain imports of .css files, which we want rollup 63 | // to leave alone and keep in the published output. 64 | addon.keepAssets(['**/*.css']), 65 | 66 | // Remove leftover build artifacts when starting a new build. 67 | addon.clean() 68 | ] 69 | }; 70 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 6.0.1 4 | 5 | - Convert to v2 addon using newer blueprint 6 | 7 | ## 6.0.0 8 | 9 | - Convert to v2 addon 10 | 11 | ## 5.2.2 12 | 13 | - Run ember-cli-update 14 | - Upgrade dependencies 15 | 16 | ## 5.2.1 17 | 18 | - Correct implementation from 5.2.0 19 | 20 | ## 5.2.0 21 | 22 | - Pass options through to `.focus()` 23 | 24 | ## 5.1.3 25 | 26 | - Upgrade dependencies 27 | - Convert tests to `.gjs` 28 | 29 | ## 5.1.2 30 | 31 | - Update `@zestia` scoped packages 32 | 33 | ## 5.1.1 34 | 35 | - Re-release of 5.1.0 but published to GH Packages instead of NPM 36 | 37 | ## 5.1.0 38 | 39 | - Run `ember-cli-update` 40 | 41 | ## 5.0.0 42 | 43 | - Update `ember-modifier` to v4 44 | 45 | ## 4.5.1 46 | 47 | - Fix accidental release of a major version change of `ember-modifier` 48 | 49 | ## 4.5.0 50 | 51 | - Upgrade dependencies 52 | 53 | ## 4.4.0 54 | 55 | - Upgrade dependencies 56 | 57 | ## 4.3.0 58 | 59 | - Resolve ember-modifier deprecations 60 | 61 | ## 4.2.0 62 | 63 | - Upgrade dependencies 64 | - Add Embroider support 65 | - Allow nesting of auto-focus modifier 66 | 67 | ## 4.1.7 68 | 69 | - Upgrade dependencies 70 | 71 | ## 4.1.6 72 | 73 | - Upgrade dependencies 74 | - Run ember-cli-update 75 | 76 | ## 4.1.5 77 | 78 | - Upgrade dependencies 79 | 80 | ## 4.1.4 81 | 82 | - Upgrade dependencies 83 | 84 | ## 4.1.3 85 | 86 | - Upgrade dependencies 87 | 88 | ## 4.1.2 89 | 90 | - Upgrade dependencies 91 | 92 | ## 4.1.1 93 | 94 | - Upgrade dependencies 95 | 96 | ## 4.1.0 97 | 98 | - Correct typo with `programmaticallyFocused` 99 | 100 | ## 4.0.6 101 | 102 | - Upgrade dependencies 103 | 104 | ## 4.0.5 105 | 106 | - Upgrade dependencies 107 | 108 | ## 4.0.4 109 | 110 | - Upgrade dependencies 111 | 112 | ## 4.0.3 113 | 114 | - Upgrade dependencies 115 | 116 | ## 4.0.2 117 | 118 | - Wait until afterRender before focusing 119 | (Fixes focusin not firing on parent node) 120 | 121 | ## 4.0.1 122 | 123 | - Rename export 124 | 125 | ## 4.0.0 126 | 127 | - BREAKING: Switch from a component to an element modifier 128 | 129 | ## 3.0.11 130 | 131 | - Migrate to Glimmer component 132 | 133 | ## 3.0.10 134 | 135 | - Upgrade dependencies 136 | 137 | ## 3.0.9 138 | 139 | - Fix backwards compatibility 140 | 141 | ## 3.0.8 142 | 143 | - Internal refactor 144 | - Upgrade dependencies 145 | 146 | ## 3.0.7 147 | 148 | - Upgrade dependencies 149 | 150 | ## 3.0.6 151 | 152 | - Upgrade dependencies 153 | 154 | ## 3.0.5 155 | 156 | - Upgrade dependencies 157 | 158 | ## 3.0.4 159 | 160 | - Introduce a way to distinguish between user focus and programmatic focus 161 | 162 | ## 3.0.3 163 | 164 | - Update dependencies 165 | 166 | ## 3.0.2 167 | 168 | - Update dependencies 169 | 170 | ## 3.0.0 171 | 172 | - Removes use of positional parameter `{{auto-focus ".my-element"}}` 173 | 174 | ## < 3.0.0 175 | 176 | - No changelog 177 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Debugging: 3 | * https://eslint.org/docs/latest/use/configure/debug 4 | * ---------------------------------------------------- 5 | * 6 | * Print a file's calculated configuration 7 | * 8 | * npx eslint --print-config path/to/file.js 9 | * 10 | * Inspecting the config 11 | * 12 | * npx eslint --inspect-config 13 | * 14 | */ 15 | import babelParser from '@babel/eslint-parser'; 16 | import js from '@eslint/js'; 17 | import prettier from 'eslint-config-prettier'; 18 | import ember from 'eslint-plugin-ember/recommended'; 19 | import importPlugin from 'eslint-plugin-import'; 20 | import n from 'eslint-plugin-n'; 21 | import globals from 'globals'; 22 | import zestia from '@zestia/eslint-config'; 23 | 24 | const esmParserOptions = { 25 | ecmaFeatures: { modules: true }, 26 | ecmaVersion: 'latest' 27 | }; 28 | 29 | const config = [ 30 | js.configs.recommended, 31 | prettier, 32 | ember.configs.base, 33 | ember.configs.gjs, 34 | zestia, 35 | // Temporary 36 | { 37 | rules: { 38 | 'no-restricted-imports': 'off' 39 | } 40 | }, 41 | /** 42 | * Ignores must be in their own object 43 | * https://eslint.org/docs/latest/use/configure/ignore 44 | */ 45 | { 46 | ignores: [ 47 | 'dist/', 48 | 'dist-*/', 49 | 'declarations/', 50 | 'node_modules/', 51 | 'coverage/', 52 | '!**/.*' 53 | ] 54 | }, 55 | /** 56 | * https://eslint.org/docs/latest/use/configure/configuration-files#configuring-linter-options 57 | */ 58 | { 59 | linterOptions: { 60 | reportUnusedDisableDirectives: 'error' 61 | } 62 | }, 63 | { 64 | files: ['**/*.js'], 65 | languageOptions: { 66 | parser: babelParser 67 | } 68 | }, 69 | { 70 | files: ['**/*.{js,gjs}'], 71 | languageOptions: { 72 | parserOptions: esmParserOptions, 73 | globals: { 74 | ...globals.browser 75 | } 76 | } 77 | }, 78 | { 79 | files: ['src/**/*'], 80 | plugins: { 81 | import: importPlugin 82 | }, 83 | rules: { 84 | // require relative imports use full extensions 85 | 'import/extensions': ['error', 'always', { ignorePackages: true }] 86 | } 87 | }, 88 | /** 89 | * CJS node files 90 | */ 91 | { 92 | files: [ 93 | '**/*.cjs', 94 | '.prettierrc.cjs', 95 | '.template-lintrc.cjs', 96 | 'addon-main.cjs' 97 | ], 98 | plugins: { 99 | n 100 | }, 101 | 102 | languageOptions: { 103 | sourceType: 'script', 104 | ecmaVersion: 'latest', 105 | globals: { 106 | ...globals.node 107 | } 108 | } 109 | }, 110 | /** 111 | * ESM node files 112 | */ 113 | { 114 | files: ['**/*.mjs'], 115 | plugins: { 116 | n 117 | }, 118 | 119 | languageOptions: { 120 | sourceType: 'module', 121 | ecmaVersion: 'latest', 122 | parserOptions: esmParserOptions, 123 | globals: { 124 | ...globals.node 125 | } 126 | } 127 | } 128 | ]; 129 | 130 | export default config; 131 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @zestia/ember-auto-focus 2 | 3 | 4 | 5 | 6 | [npm-badge]: https://img.shields.io/npm/v/@zestia/ember-auto-focus.svg 7 | [npm-badge-url]: https://www.npmjs.com/package/@zestia/ember-auto-focus 8 | [github-actions-badge]: https://github.com/zestia/ember-auto-focus/workflows/CI/badge.svg 9 | [github-actions-url]: https://github.com/zestia/ember-auto-focus/actions 10 | [ember-observer-badge]: https://emberobserver.com/badges/-zestia-ember-auto-focus.svg 11 | [ember-observer-url]: https://emberobserver.com/addons/@zestia/ember-auto-focus 12 | 13 | HTML's `autofocus` attribute focuses an element on the first occurrence of the attribute. But, does nothing on subsequent renders of the same element. 14 | 15 | This addon provides an element modifier, which auto focuses the element when it is inserted into the DOM. 16 | 17 | ## Installation 18 | 19 | ``` 20 | ember install @zestia/ember-auto-focus 21 | ``` 22 | 23 | Add the following to `~/.npmrc` to pull @zestia scoped packages from Github instead of NPM. 24 | 25 | ``` 26 | @zestia:registry=https://npm.pkg.github.com 27 | //npm.pkg.github.com/:_authToken= 28 | ``` 29 | 30 | ## Demo 31 | 32 | https://zestia.github.io/ember-auto-focus 33 | 34 | ## Example 35 | 36 | ```handlebars 37 | {{#if this.showField}} 38 | 39 | {{/if}} 40 | ``` 41 | 42 | ## `{{autoFocus}}` 43 | 44 | ### Arguments 45 | 46 | #### `selector` 47 | 48 | Optional. This _positional_ argument allows you to auto focus a child element. Useful for occasions when you don't have access to the children. 49 | 50 |
51 | Example 52 | 53 | ```handlebars 54 | 55 | ``` 56 | 57 |
58 | 59 | #### `disabled` 60 | 61 | Optional. This _named_ argument turns off auto focusing. Note that this behaviour can now also be achieved with a conditional modifier (this wasn't always the case). 62 | 63 | #### `options` 64 | 65 | Optional. _Any other named arguments_ are passed to the `focus` method. Some options available include `preventScroll` and `focusVisible` 66 | 67 | ## Notes 68 | 69 | This modifier has certain benefits over other implementations: 70 | 71 | 1. It waits until after render, so that in your actions you can be sure `document.activeElement` is as you'd expect ([Example](https://github.com/zestia/ember-auto-focus/blob/845ea30035aa55fb69164e9eb9001c6fe08fa73b/tests/integration/modifiers/auto-focus-test.js#L86-L98)). 72 | 73 | 2. It compensates for the fact that child modifiers run their installation before parents in the DOM tree. So nesting `{{autoFocus}}` will work as you would expect. ([Example](https://github.com/zestia/ember-auto-focus/blob/845ea30035aa55fb69164e9eb9001c6fe08fa73b/tests/integration/modifiers/auto-focus-test.js#L100-L114)). 74 | 75 | 3. It allows you to differentiate between an element that was focused by a user interacting with it, and an element that was focused programmatically. Through `element.dataset.programmaticallyFocused`. ([Example](https://github.com/zestia/ember-auto-focus/blob/8ba15763e5b21e5cc7924339dd65521c965ce722/tests/integration/modifiers/auto-focus-test.js#L116-L144)) 76 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@zestia/ember-auto-focus", 3 | "version": "6.0.1", 4 | "description": "An HTML autofocus solution for Ember apps", 5 | "keywords": [ 6 | "ember-addon", 7 | "focus", 8 | "autofocus", 9 | "input", 10 | "textarea", 11 | "modifier" 12 | ], 13 | "repository": "", 14 | "license": "MIT", 15 | "author": "", 16 | "files": [ 17 | "addon-main.cjs", 18 | "declarations", 19 | "dist" 20 | ], 21 | "scripts": { 22 | "build": "rollup --config", 23 | "format": "prettier . --cache --write", 24 | "lint": "concurrently \"npm:lint:*(!fix)\" --names \"lint:\" --prefixColors auto", 25 | "lint:fix": "concurrently \"npm:lint:*:fix\" --names \"fix:\" --prefixColors auto && npm run format", 26 | "lint:format": "prettier . --cache --check", 27 | "lint:hbs": "ember-template-lint . --no-error-on-unmatched-pattern", 28 | "lint:js": "eslint . --cache", 29 | "lint:hbs:fix": "ember-template-lint . --fix --no-error-on-unmatched-pattern", 30 | "lint:js:fix": "eslint . --fix", 31 | "lint:css": "stylelint '**/*.{css,scss}'", 32 | "lint:css:fix": "concurrently \"npm:lint:css -- --fix\"", 33 | "start": "vite dev", 34 | "test": "vite build --mode=development --out-dir dist-tests && testem --file testem.cjs ci --port 0", 35 | "prepack": "rollup --config", 36 | "release": "release-it" 37 | }, 38 | "dependencies": { 39 | "@embroider/addon-shim": "^1.8.9", 40 | "decorator-transforms": "^2.2.2", 41 | "ember-modifier": "^4.2.0" 42 | }, 43 | "devDependencies": { 44 | "@babel/core": "^7.25.2", 45 | "@babel/eslint-parser": "^7.25.1", 46 | "@babel/runtime": "^7.25.6", 47 | "@ember/test-helpers": "^5.2.1", 48 | "@embroider/addon-dev": "^8.1.0", 49 | "@embroider/compat": "^4.1.0", 50 | "@embroider/core": "^4.1.0", 51 | "@embroider/macros": "^1.18.0", 52 | "@embroider/vite": "^1.1.5", 53 | "@eslint/js": "^9.17.0", 54 | "@glimmer/component": "^2.0.0", 55 | "@rollup/plugin-babel": "^6.0.4", 56 | "@zestia/eslint-config": "^7.0.2", 57 | "@zestia/prettier-config": "^1.3.5", 58 | "@zestia/stylelint-config": "^6.1.1", 59 | "@zestia/template-lint-config": "^6.3.0", 60 | "babel-plugin-ember-template-compilation": "^2.2.5", 61 | "concurrently": "^9.0.1", 62 | "ember-qunit": "^9.0.2", 63 | "ember-resolver": "^13.1.0", 64 | "ember-route-template": "^1.0.3", 65 | "ember-source": "^6.3.0", 66 | "ember-template-lint": "^7.9.0", 67 | "eslint": "^9.17.0", 68 | "eslint-config-prettier": "^10.1.5", 69 | "eslint-plugin-ember": "^12.3.3", 70 | "eslint-plugin-import": "^2.31.0", 71 | "eslint-plugin-n": "^17.15.1", 72 | "globals": "^16.1.0", 73 | "prettier": "^3.4.2", 74 | "prettier-plugin-ember-template-tag": "^2.0.4", 75 | "qunit": "^2.24.1", 76 | "qunit-dom": "^3.4.0", 77 | "release-it": "^17.0.1", 78 | "rollup": "^4.22.5", 79 | "testem": "^3.15.1", 80 | "vite": "^6.2.4" 81 | }, 82 | "ember": { 83 | "edition": "octane" 84 | }, 85 | "ember-addon": { 86 | "version": 2, 87 | "type": "addon", 88 | "main": "addon-main.cjs", 89 | "app-js": { 90 | "./modifiers/auto-focus.js": "./dist/_app_/modifiers/auto-focus.js" 91 | } 92 | }, 93 | "imports": { 94 | "#app/*": "./demo/*", 95 | "#src/*": "./src/*" 96 | }, 97 | "exports": { 98 | ".": "./dist/index.js", 99 | "./*": "./dist/*.js", 100 | "./addon-main.js": "./addon-main.cjs" 101 | }, 102 | "publishConfig": { 103 | "registry": "https://npm.pkg.github.com" 104 | }, 105 | "release-it": { 106 | "hooks": { 107 | "before:init": [ 108 | "npm run lint", 109 | "npm test" 110 | ] 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /tests/integration/auto-focus-test.gjs: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | import { setupRenderingTest } from 'ember-qunit'; 3 | import { render, find, rerender } from '@ember/test-helpers'; 4 | import autoFocus from '@zestia/ember-auto-focus/modifiers/auto-focus'; 5 | import { tracked } from '@glimmer/tracking'; 6 | import { on } from '@ember/modifier'; 7 | 8 | module('autoFocus', function (hooks) { 9 | setupRenderingTest(hooks); 10 | 11 | test('it focuses the element', async function (assert) { 12 | assert.expect(3); 13 | 14 | const state = new (class { 15 | @tracked show = true; 16 | })(); 17 | 18 | state.show = true; 19 | 20 | await render( 21 | 26 | ); 27 | 28 | assert.dom('.foo').isFocused('the element is focused on initial render'); 29 | 30 | state.show = false; 31 | 32 | await rerender(); 33 | 34 | assert 35 | .dom('.foo') 36 | .doesNotExist('precondition, element is removed from the DOM'); 37 | 38 | state.show = true; 39 | 40 | await rerender(); 41 | 42 | assert 43 | .dom('.foo') 44 | .isFocused('the element is focused on subsequent renders'); 45 | }); 46 | 47 | test('it can focus a specific child element', async function (assert) { 48 | assert.expect(1); 49 | 50 | const selector = '.inner > .foo'; 51 | 52 | await render( 53 | 60 | ); 61 | 62 | assert 63 | .dom(selector) 64 | .isFocused('the element specified by the selector is focused'); 65 | }); 66 | 67 | test('it does not focus an element outside of itself', async function (assert) { 68 | assert.expect(1); 69 | 70 | await render( 71 | 75 | ); 76 | 77 | assert 78 | .dom('.focusable') 79 | .isNotFocused('the selector is scoped to child elements only'); 80 | }); 81 | 82 | test('disabled argument (disabled)', async function (assert) { 83 | assert.expect(1); 84 | 85 | await render( 86 | 89 | ); 90 | 91 | assert.dom('.foo').isNotFocused('does not focus the element'); 92 | }); 93 | 94 | test('disabled argument (enabled)', async function (assert) { 95 | assert.expect(1); 96 | 97 | await render( 98 | 101 | ); 102 | 103 | assert.dom('.foo').isFocused('focus the element'); 104 | }); 105 | 106 | test('rendering', async function (assert) { 107 | assert.expect(2); 108 | 109 | const focusInOuter = () => assert.step('focusin on parent node'); 110 | 111 | await render( 112 | 117 | ); 118 | 119 | assert.verifySteps(['focusin on parent node']); 120 | }); 121 | 122 | test('nesting', async function (assert) { 123 | assert.expect(1); 124 | 125 | await render( 126 | 132 | ); 133 | 134 | assert.dom('.inner').isFocused( 135 | `child modifiers run before parents, but this scenario behaves as expected 136 | (because the parent renders first, then the child)` 137 | ); 138 | }); 139 | 140 | test('programmatic focus', async function (assert) { 141 | assert.expect(2); 142 | 143 | const focused = () => { 144 | assert 145 | .dom('.foo') 146 | .hasAttribute( 147 | 'data-programmatically-focused', 148 | 'true', 149 | 'property is true because this addon focused the element' 150 | ); 151 | }; 152 | 153 | await render( 154 | 162 | ); 163 | 164 | assert 165 | .dom('.foo') 166 | .doesNotHaveAttribute( 167 | 'data-programmatically-focused', 168 | 'property removed after focus' 169 | ); 170 | }); 171 | 172 | test('other options', async function (assert) { 173 | assert.expect(1); 174 | 175 | await render( 176 | 193 | ); 194 | 195 | assert.strictEqual( 196 | find('.parent').scrollTop, 197 | 0, 198 | 'can pass extra options to focus method' 199 | ); 200 | }); 201 | }); 202 | --------------------------------------------------------------------------------