mirror of
https://github.com/atom/atom.git
synced 2026-04-06 03:02:13 -04:00
This implements automatic focus management for modal panels using the excellent focus-trap module. Upon being shown, modals will have their first tabbable element automatically focused, and shifting focus with the tab key (or more correctly the core:focus-next command) will be limited to the contents of the modal. If the modal does not have any tabbable elements, focus() will be sent to the panel's root element (if it implements it). I'm happy to update this to *always* calls focus on the panel's root element, but then modal implementers would need to handle that and focus things on their own. I'd argue the tabbable element behavior is more accessible though :) This has the effect of not automatically closing most of Atom's own modals whenever the tab key is pressed, which was an odd nonstandard behavior, IMO. This also automates returning focus to the element that had focus before the modal was shown, something up until now had to be implemented in every modal in Atom. This likely breaks a few contracts for existing Atom packages that create modals, but I've found this doesn't conflict behaviorally with well-behaved modals like the command palette which implement their own focus management (which can be removed if this lands). Released under CC0.
204 lines
7.2 KiB
JavaScript
204 lines
7.2 KiB
JavaScript
'use strict'
|
|
|
|
const Panel = require('../src/panel')
|
|
const PanelContainer = require('../src/panel-container')
|
|
|
|
describe('PanelContainerElement', () => {
|
|
let jasmineContent, element, container
|
|
|
|
class TestPanelContainerItem {
|
|
}
|
|
|
|
class TestPanelContainerItemElement_ extends HTMLElement {
|
|
createdCallback () {
|
|
this.classList.add('test-root')
|
|
}
|
|
initialize (model) {
|
|
this.model = model
|
|
return this
|
|
}
|
|
focus() {}
|
|
}
|
|
|
|
const TestPanelContainerItemElement = document.registerElement(
|
|
'atom-test-container-item-element',
|
|
{prototype: TestPanelContainerItemElement_.prototype}
|
|
)
|
|
|
|
beforeEach(() => {
|
|
jasmineContent = document.body.querySelector('#jasmine-content')
|
|
|
|
atom.views.addViewProvider(
|
|
TestPanelContainerItem,
|
|
model => new TestPanelContainerItemElement().initialize(model)
|
|
)
|
|
|
|
container = new PanelContainer({viewRegistry: atom.views, location: 'left'})
|
|
element = container.getElement()
|
|
jasmineContent.appendChild(element)
|
|
})
|
|
|
|
it('has a location class with value from the model', () => {
|
|
expect(element).toHaveClass('left')
|
|
})
|
|
|
|
it('removes the element when the container is destroyed', () => {
|
|
expect(element.parentNode).toBe(jasmineContent)
|
|
container.destroy()
|
|
expect(element.parentNode).not.toBe(jasmineContent)
|
|
})
|
|
|
|
describe('adding and removing panels', () => {
|
|
it('allows panels to be inserted at any position', () => {
|
|
const panel1 = new Panel({item: new TestPanelContainerItem(), priority: 10}, atom.views)
|
|
const panel2 = new Panel({item: new TestPanelContainerItem(), priority: 5}, atom.views)
|
|
const panel3 = new Panel({item: new TestPanelContainerItem(), priority: 8}, atom.views)
|
|
|
|
container.addPanel(panel1)
|
|
container.addPanel(panel2)
|
|
container.addPanel(panel3)
|
|
|
|
expect(element.childNodes[2]).toBe(panel1.getElement())
|
|
expect(element.childNodes[1]).toBe(panel3.getElement())
|
|
expect(element.childNodes[0]).toBe(panel2.getElement())
|
|
})
|
|
|
|
describe('when the container is at the left location', () =>
|
|
it('adds atom-panel elements when a new panel is added to the container; removes them when the panels are destroyed', () => {
|
|
expect(element.childNodes.length).toBe(0)
|
|
|
|
const panel1 = new Panel({item: new TestPanelContainerItem()}, atom.views)
|
|
container.addPanel(panel1)
|
|
expect(element.childNodes.length).toBe(1)
|
|
expect(element.childNodes[0]).toHaveClass('left')
|
|
expect(element.childNodes[0]).toHaveClass('tool-panel') // legacy selector support
|
|
expect(element.childNodes[0]).toHaveClass('panel-left') // legacy selector support
|
|
|
|
expect(element.childNodes[0].tagName).toBe('ATOM-PANEL')
|
|
|
|
const panel2 = new Panel({item: new TestPanelContainerItem()}, atom.views)
|
|
container.addPanel(panel2)
|
|
expect(element.childNodes.length).toBe(2)
|
|
|
|
expect(panel1.getElement().style.display).not.toBe('none')
|
|
expect(panel2.getElement().style.display).not.toBe('none')
|
|
|
|
panel1.destroy()
|
|
expect(element.childNodes.length).toBe(1)
|
|
|
|
panel2.destroy()
|
|
expect(element.childNodes.length).toBe(0)
|
|
})
|
|
)
|
|
|
|
describe('when the container is at the bottom location', () => {
|
|
beforeEach(() => {
|
|
container = new PanelContainer({viewRegistry: atom.views, location: 'bottom'})
|
|
element = container.getElement()
|
|
jasmineContent.appendChild(element)
|
|
})
|
|
|
|
it('adds atom-panel elements when a new panel is added to the container; removes them when the panels are destroyed', () => {
|
|
expect(element.childNodes.length).toBe(0)
|
|
|
|
const panel1 = new Panel({item: new TestPanelContainerItem(), className: 'one'}, atom.views)
|
|
container.addPanel(panel1)
|
|
expect(element.childNodes.length).toBe(1)
|
|
expect(element.childNodes[0]).toHaveClass('bottom')
|
|
expect(element.childNodes[0]).toHaveClass('tool-panel') // legacy selector support
|
|
expect(element.childNodes[0]).toHaveClass('panel-bottom') // legacy selector support
|
|
expect(element.childNodes[0].tagName).toBe('ATOM-PANEL')
|
|
expect(panel1.getElement()).toHaveClass('one')
|
|
|
|
const panel2 = new Panel({item: new TestPanelContainerItem(), className: 'two'}, atom.views)
|
|
container.addPanel(panel2)
|
|
expect(element.childNodes.length).toBe(2)
|
|
expect(panel2.getElement()).toHaveClass('two')
|
|
|
|
panel1.destroy()
|
|
expect(element.childNodes.length).toBe(1)
|
|
|
|
panel2.destroy()
|
|
expect(element.childNodes.length).toBe(0)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('when the container is modal', () => {
|
|
beforeEach(() => {
|
|
container = new PanelContainer({viewRegistry: atom.views, location: 'modal'})
|
|
element = container.getElement()
|
|
jasmineContent.appendChild(element)
|
|
})
|
|
|
|
it('allows only one panel to be visible at a time', () => {
|
|
const panel1 = new Panel({item: new TestPanelContainerItem()}, atom.views)
|
|
container.addPanel(panel1)
|
|
|
|
expect(panel1.getElement().style.display).not.toBe('none')
|
|
|
|
const panel2 = new Panel({item: new TestPanelContainerItem()}, atom.views)
|
|
container.addPanel(panel2)
|
|
|
|
expect(panel1.getElement().style.display).toBe('none')
|
|
expect(panel2.getElement().style.display).not.toBe('none')
|
|
|
|
panel1.show()
|
|
|
|
expect(panel1.getElement().style.display).not.toBe('none')
|
|
expect(panel2.getElement().style.display).toBe('none')
|
|
})
|
|
|
|
it("adds the 'modal' class to panels", () => {
|
|
const panel1 = new Panel({item: new TestPanelContainerItem()}, atom.views)
|
|
container.addPanel(panel1)
|
|
|
|
expect(panel1.getElement()).toHaveClass('modal')
|
|
|
|
// legacy selector support
|
|
expect(panel1.getElement()).not.toHaveClass('tool-panel')
|
|
expect(panel1.getElement()).toHaveClass('overlay')
|
|
expect(panel1.getElement()).toHaveClass('from-top')
|
|
})
|
|
|
|
it("focuses the first tabbable item if available", () => {
|
|
const panel1 = new Panel({item: new TestPanelContainerItem(), visible: false}, atom.views)
|
|
container.addPanel(panel1)
|
|
|
|
const panelEl = panel1.getElement()
|
|
const inputEl = document.createElement('input')
|
|
panelEl.appendChild(inputEl)
|
|
|
|
expect(document.activeElement).not.toBe(inputEl)
|
|
panel1.show()
|
|
expect(document.activeElement).toBe(inputEl)
|
|
})
|
|
|
|
it("focuses the entire panel item when no tabbable item is available and the panel is focusable", () => {
|
|
const panel1 = new Panel({item: new TestPanelContainerItem(), visible: false}, atom.views)
|
|
container.addPanel(panel1)
|
|
const panelEl = panel1.getElement()
|
|
|
|
spyOn(panelEl, 'focus')
|
|
panel1.show()
|
|
expect(panelEl.focus).toHaveBeenCalled()
|
|
})
|
|
|
|
it("returns focus to the original activeElement", () => {
|
|
const panel1 = new Panel({item: new TestPanelContainerItem(), visible: false}, atom.views)
|
|
container.addPanel(panel1)
|
|
const previousActiveElement = document.activeElement
|
|
const panelEl = panel1.getElement()
|
|
panelEl.appendChild(document.createElement('input'))
|
|
|
|
panel1.show()
|
|
panel1.hide()
|
|
|
|
waitsFor(() => document.activeElement === previousActiveElement)
|
|
runs(() => {
|
|
expect(document.activeElement).toBe(previousActiveElement)
|
|
})
|
|
})
|
|
})
|
|
})
|