Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dropdown: Allow cycling inside #38828

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions js/src/dropdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ const PLACEMENT_BOTTOMCENTER = 'bottom'
const Default = {
autoClose: true,
boundary: 'clippingParents',
cycling: true,
display: 'dynamic',
offset: [0, 2],
popperConfig: null,
Expand All @@ -80,6 +81,7 @@ const Default = {
const DefaultType = {
autoClose: '(boolean|string)',
boundary: '(string|element)',
cycling: 'boolean',
display: 'string',
offset: '(array|string|function)',
popperConfig: '(null|object|function)',
Expand Down Expand Up @@ -331,9 +333,8 @@ class Dropdown extends BaseComponent {
return
}

// if target isn't included in items (e.g. when expanding the dropdown)
// allow cycling to get the last item in case key equals ARROW_UP_KEY
getNextActiveElement(items, target, key === ARROW_DOWN_KEY, !items.includes(target)).focus()
// Allow cycling with up and down arrows
getNextActiveElement(items, target, key === ARROW_DOWN_KEY, this._config.cycling || !items.includes(target)).focus()
}

// Static
Expand Down
75 changes: 75 additions & 0 deletions js/tests/unit/dropdown.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1819,6 +1819,81 @@ describe('Dropdown', () => {
})
})

it('should cycle and focus on the last item when using ArrowUp for the first time, respectively with ArrowDown', () => {
return new Promise(resolve => {
fixtureEl.innerHTML = [
'<div class="dropdown">',
' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
' <div class="dropdown-menu">',
' <a id="item1" class="dropdown-item" href="#">A link</a>',
' <a id="item2" class="dropdown-item" href="#">Another link</a>',
' </div>',
'</div>'
].join('')

const triggerDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
const triggerItem1 = fixtureEl.querySelector('#item1')
const triggerItem2 = fixtureEl.querySelector('#item2')

const keydown = createEvent('keydown')
keydown.key = 'ArrowDown'

const keydown2 = createEvent('keydown')
keydown2.key = 'ArrowUp'

triggerDropdown.dispatchEvent(keydown)
triggerItem1.dispatchEvent(keydown2)

setTimeout(() => {
expect(document.activeElement).toEqual(triggerItem2, 'item2 is focused')
triggerItem2.dispatchEvent(keydown)

setTimeout(() => {
expect(document.activeElement).toEqual(triggerItem1, 'item1 is focused')
resolve()
}, 20)
}, 20)
})
})

it('should not cycle and stay focus on the first item when using ArrowUp and respectively with last item and ArrowDown', () => {
return new Promise(resolve => {
fixtureEl.innerHTML = [
'<div class="dropdown">',
' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" data-bs-cycling="false">Dropdown</button>',
' <div class="dropdown-menu">',
' <a id="item1" class="dropdown-item" href="#">A link</a>',
' <a id="item2" class="dropdown-item" href="#">Another link</a>',
' </div>',
'</div>'
].join('')

const triggerDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
const triggerItem1 = fixtureEl.querySelector('#item1')
const triggerItem2 = fixtureEl.querySelector('#item2')

const keydown = createEvent('keydown')
keydown.key = 'ArrowDown'

const keydown2 = createEvent('keydown')
keydown2.key = 'ArrowUp'

triggerDropdown.dispatchEvent(keydown)
triggerItem1.dispatchEvent(keydown2)

setTimeout(() => {
expect(document.activeElement).toEqual(triggerItem1, 'item1 is focused')
triggerItem1.dispatchEvent(keydown)
triggerItem2.dispatchEvent(keydown)

setTimeout(() => {
expect(document.activeElement).toEqual(triggerItem2, 'item2 is focused')
resolve()
}, 20)
}, 20)
})
})

it('should not close the dropdown if the user clicks on a text field within dropdown-menu', () => {
return new Promise(resolve => {
fixtureEl.innerHTML = [
Expand Down
1 change: 1 addition & 0 deletions site/content/docs/5.3/components/dropdowns.md
Original file line number Diff line number Diff line change
Expand Up @@ -1078,6 +1078,7 @@ const dropdownList = [...dropdownElementList].map(dropdownToggleEl => new bootst
| --- | --- | --- | --- |
| `autoClose` | boolean, string | `true` | Configure the auto close behavior of the dropdown: <ul class="my-2"><li>`true` - the dropdown will be closed by clicking outside or inside the dropdown menu.</li><li>`false` - the dropdown will be closed by clicking the toggle button and manually calling `hide` or `toggle` method. (Also will not be closed by pressing <kbd>Esc</kbd> key)</li><li>`'inside'` - the dropdown will be closed (only) by clicking inside the dropdown menu.</li> <li>`'outside'` - the dropdown will be closed (only) by clicking outside the dropdown menu.</li></ul> Note: the dropdown can always be closed with the <kbd>Esc</kbd> key. |
| `boundary` | string, element | `'clippingParents'` | Overflow constraint boundary of the dropdown menu (applies only to Popper's preventOverflow modifier). By default it's `clippingParents` and can accept an HTMLElement reference (via JavaScript only). For more information refer to Popper's [detectOverflow docs](https://popper.js.org/docs/v2/utils/detect-overflow/#boundary). |
| `cycling` | boolean | `true` | Configure cycling among `.dropdown-item` using `up` and `down` arrow. |
| `display` | string | `'dynamic'` | By default, we use Popper for dynamic positioning. Disable this with `static`. |
| `offset` | array, string, function | `[0, 2]` | Offset of the dropdown relative to its target. You can pass a string in data attributes with comma separated values like: `data-bs-offset="10,20"`. When a function is used to determine the offset, it is called with an object containing the popper placement, the reference, and popper rects as its first argument. The triggering element DOM node is passed as the second argument. The function must return an array with two numbers: [skidding](https://popper.js.org/docs/v2/modifiers/offset/#skidding-1), [distance](https://popper.js.org/docs/v2/modifiers/offset/#distance-1). For more information refer to Popper's [offset docs](https://popper.js.org/docs/v2/modifiers/offset/#options). |
| `popperConfig` | null, object, function | `null` | To change Bootstrap's default Popper config, see [Popper's configuration](https://popper.js.org/docs/v2/constructors/#options). When a function is used to create the Popper configuration, it's called with an object that contains the Bootstrap's default Popper configuration. It helps you use and merge the default with your own configuration. The function must return a configuration object for Popper. |
Expand Down
Loading