Skip to content

Commit

Permalink
Merge pull request #1091 from rajbirsingh83/rsinghko/setfocus
Browse files Browse the repository at this point in the history
  • Loading branch information
elwayman02 authored Aug 20, 2024
2 parents 2dc17cb + 552da58 commit e9a6f37
Show file tree
Hide file tree
Showing 7 changed files with 349 additions and 6 deletions.
51 changes: 50 additions & 1 deletion addon/modifiers/scroll-into-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,43 @@ import { modifier } from 'ember-modifier';

export default modifier(scrollIntoView, { eager: false });

/**
* Modifier that scrolls an element into view.
* You may specify top or left offset to use `scrollTo` instead of default `scrollIntoView`.
* You may also let it focus on an element after scrolling. If focus selector is not provided,
* it will focus on the first focusable element found.
* @param {Element} element - Element to scroll into view
* @param {Array} positional - Positional arguments passed to the modifier
* @param {Object} named - Named arguments passed to the modifier
* @param {boolean} named.shouldScroll - Whether to scroll the element into view
* @param {Object} named.options - Options to pass to `scrollIntoView` or `scrollTo`
* @param {string} named.options.behavior - Scroll behavior to pass to `scrollIntoView` or `scrollTo`
* @param {number} named.options.topOffset - Top offset to pass to `scrollTo`
* @param {number} named.options.leftOffset - Left offset to pass to `scrollTo`
* @param {string} named.options.scrollContainerId - Id of the scroll container to pass to `scrollTo`
* @param {boolean} named.shouldFocusAfterScroll - Whether to focus on an element after scrolling
* @param {string} named.focusSelector - Selector to find the element to focus on after scrolling
* @returns {Function} - Function to remove the modifier
* @example
* ```hbs
* <div {{scroll-into-view shouldScroll=true}}/>
* <div {{scroll-into-view shouldScroll=true options=(hash behavior="smooth")}}/>
* <div {{scroll-into-view shouldScroll=true options=(hash behavior="smooth" topOffset=25 leftOffset=25)}}/>
* <div {{scroll-into-view shouldScroll=true shouldFocusAfterScroll=true}}/>
* <div {{scroll-into-view shouldScroll=true shouldFocusAfterScroll=true focusSelector="input[aria-invalid='true']"}}/>
* ```
*/
function scrollIntoView(element, positional, named = {}) {
const { options, shouldScroll } = named;
const { options, shouldScroll, shouldFocusAfterScroll, focusSelector } =
named;
const DEFAULT_FOCUSABLE_ELEMENTS = [
'button:not(:disabled)',
'[href]',
'input:not(:disabled)',
'select:not(:disabled)',
'textarea:not(:disabled)',
'[tabindex]:not([tabindex="-1"]):not(:disabled)',
];
let hasBeenRemoved;

const shouldScrollPromise = Promise.resolve(shouldScroll);
Expand Down Expand Up @@ -52,6 +87,20 @@ function scrollIntoView(element, positional, named = {}) {
left,
});
}
if (shouldFocusAfterScroll) {
let focusElement;
if (typeof focusSelector === 'string') {
focusElement = element.querySelector(focusSelector);
}
// When provided focusable element doesn't exist, fallback to first focusable element
focusElement =
focusElement ??
element.querySelector(DEFAULT_FOCUSABLE_ELEMENTS.join(', '));
if (focusElement) {
// Prevent scrolling while setting focus to avoid overriding the above scroll behavior.
focusElement.focus({ preventScroll: true });
}
}
}
});

Expand Down
18 changes: 17 additions & 1 deletion docs/modifiers/scroll-into-view.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';

export default class EsButtonComponent extends Component {
@tracked shouldScrollWithOffset;
@tracked shouldScrollWithCustom;
@tracked shouldScroll;
@tracked shouldScrollWithFocus;
@tracked shouldScrollWithFocusElement;
@tracked shouldFocusAfterScroll;
@tracked shouldFocusAfterScrollWithFocusElement;
@tracked topOffset = 25;
@tracked leftOffset = 25;
@tracked topOffsetCustom = 50;
Expand All @@ -15,6 +19,18 @@ export default class EsButtonComponent extends Component {
this.shouldScroll = true;
}

@action
onScrollIntoViewWithFocus() {
this.shouldScrollWithFocus = true;
this.shouldFocusAfterScroll = true;
}

@action
onScrollIntoViewWithFocusElement() {
this.shouldScrollWithFocusElement = true;
this.shouldFocusAfterScrollWithFocusElement = true;
}

@action
onScrollIntoViewWithOffset() {
this.shouldScrollWithOffset = true;
Expand Down
35 changes: 35 additions & 0 deletions docs/modifiers/scroll-into-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,41 @@ You should use this modifier whenever you need to have an element scrolled into

`shouldScroll` can be either a Boolean or a Promise that resolves to a truthy or falsy value. It does not handle a rejected Promise.

### Usage with focus

When passing in `shouldFocusAfterScroll` as true, it will set focus to the first focusable element found.

```handlebars
<div {{scroll-into-view shouldScroll=this.shouldScrollWithFocus options=(hash behavior="smooth") shouldFocusAfterScroll=this.shouldFocusAfterScroll}}>
<div>
<label for="firstFocusableElement">First Focusable Element: </label>
<input name="firstFocusableElement" type="text">
</div>
<button type="button" {{on "click" this.onScrollIntoViewWithFocus}}>
Trigger scroll-into-view and set focus on click
</button>
</div>
```
> Warning: While setting focus, `scroll-into-view` tries to prevent overriding its scroll behavior via [preventScroll](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus#preventscroll). However, it is not guaranteed in browsers that do not [support](https://caniuse.com/mdn-api_htmlelement_focus_options_preventscroll_parameter) `preventScroll`. As such, page will scroll to the focused element and `smooth` scroll behavior will be lost in such cases.
### Usage with focus element

When passing in `shouldFocusAfterScroll` as true and `focusSelector`, it will set focus to the given focusable element.

```handlebars
<div {{scroll-into-view shouldScroll=this.shouldScrollWithFocusElement options=(hash behavior="smooth") shouldFocusAfterScroll=this.shouldFocusAfterScrollWithFocusElement focusSelector="select:not(:disabled)"}}>
<button type="button" {{on "click" this.onScrollIntoViewWithFocusElement}}>
Trigger scroll-into-view and set focus on given element on click
</button>
<div>
<label for="givenFocusableElement">Given Focusable Element: </label>
<select name="givenFocusableElement">
<option>Item 1</option>
<option>Item 2</option>
</select>
</div>
</div>
```

### Usage with offset

Expand Down
4 changes: 3 additions & 1 deletion ember-cli-build.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ const EmberAddon = require('ember-cli/lib/broccoli/ember-addon');

module.exports = function (defaults) {
const app = new EmberAddon(defaults, {
// Add options here
babel: {
plugins: ['@babel/plugin-transform-class-static-block'],
},
});

/*
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"devDependencies": {
"@babel/eslint-parser": "^7.22.15",
"@babel/plugin-proposal-decorators": "^7.23.0",
"@babel/plugin-transform-class-static-block": "^7.24.7",
"@ember/optional-features": "^2.0.0",
"@ember/string": "^3.0.1",
"@ember/test-helpers": "^2.9.4",
Expand Down Expand Up @@ -98,7 +99,7 @@
"node": "16.* || >= 18"
},
"volta": {
"node": "18.12.0",
"node": "18.20.4",
"yarn": "1.22.19"
},
"ember": {
Expand Down
70 changes: 68 additions & 2 deletions tests/integration/modifiers/scroll-into-view-test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { clearRender, render } from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, clearRender } from '@ember/test-helpers';

import { hbs } from 'ember-cli-htmlbars';
import { setupRenderingTest } from 'ember-qunit';
import sinon from 'sinon';

module('Integration | Modifier | scroll-into-view', function (hooks) {
Expand Down Expand Up @@ -303,4 +304,69 @@ module('Integration | Modifier | scroll-into-view', function (hooks) {
);
});
});

module('with focus', function () {
test('it scrolls and focuses on first focusable element', async function (assert) {
await render(
hbs`<div {{scroll-into-view shouldScroll=true shouldFocusAfterScroll=true}}>
<button data-test-focus-selector />
</div>`,
);

assert.true(this.scrollIntoViewSpy.called, 'scrollIntoView was called');
assert
.dom('[data-test-focus-selector]')
.isFocused('First focusable element has focus');
});

test('it does not focus when shouldFocusAfterScroll is false', async function (assert) {
await render(
hbs`<div {{scroll-into-view shouldScroll=true shouldFocusAfterScroll=false}}>
<button data-test-focus-selector />
</div>`,
);

assert
.dom('[data-test-focus-selector]')
.isNotFocused('Focusable element does not have focus');
});

test('it does not focus when focusable element is not found', async function (assert) {
await render(
hbs`<div {{scroll-into-view shouldScroll=true shouldFocusAfterScroll=true}}>
<div data-test-non-focus-selector />
</div>`,
);

assert
.dom('[data-test-non-focus-selector]')
.isNotFocused('Non-focusable element does not have focus');
});

test('it focuses on given focusable element', async function (assert) {
await render(
hbs`<div {{scroll-into-view shouldScroll=true shouldFocusAfterScroll=true focusSelector='[data-test-focus-selector]'}}>
<button />
<button data-test-focus-selector />
</div>`,
);

assert
.dom('[data-test-focus-selector]')
.isFocused('Given focusable element has focus');
});

test('it focuses on first focusable element when given focusable element is not found', async function (assert) {
await render(
hbs`<div {{scroll-into-view shouldScroll=true shouldFocusAfterScroll=true focusSelector='[data-test-bad-selector]'}}>
<button data-test-focus-selector />
<button />
</div>`,
);

assert
.dom('[data-test-focus-selector]')
.isFocused('First focusable element has focus');
});
});
});
Loading

0 comments on commit e9a6f37

Please sign in to comment.