e03c002efd
When typing in Japanese, the Enter key is used to confirm kanji conversion during IME composition. Previously, pressing Enter would immediately save the tag, which prevented users from completing the conversion. Changes: - Add isComposing check to navigable_list_controller.js Enter handler - Add IME composition tracking to form_controller.js - Add compositionstart/compositionend event handlers to tag, step, and reaction input fields - Add preventComposingSubmit action to prevent form submission during composition This fix supports all IME input methods (Japanese, Chinese, Korean, etc.) using standard browser composition events.
259 lines
7.5 KiB
JavaScript
259 lines
7.5 KiB
JavaScript
import { Controller } from "@hotwired/stimulus"
|
|
import { nextFrame } from "helpers/timing_helpers"
|
|
|
|
export default class extends Controller {
|
|
static targets = [ "item", "input" ]
|
|
static values = {
|
|
reverseOrder: { type: Boolean, default: false },
|
|
selectionAttribute: { type: String, default: "aria-selected" },
|
|
focusOnSelection: { type: Boolean, default: true },
|
|
actionableItems: { type: Boolean, default: false },
|
|
reverseNavigation: { type: Boolean, default: false },
|
|
supportsHorizontalNavigation: { type: Boolean, default: true },
|
|
supportsVerticalNavigation: { type: Boolean, default: true },
|
|
hasNestedNavigation: { type: Boolean, default: false },
|
|
preventHandledKeys: { type: Boolean, default: false },
|
|
autoSelect: { type: Boolean, default: true },
|
|
autoScroll: { type: Boolean, default: true },
|
|
onlyActOnFocusedItems: { type: Boolean, default: false }
|
|
}
|
|
|
|
connect() {
|
|
if (this.autoSelectValue) {
|
|
this.reset()
|
|
} else {
|
|
this.#activateManualSelection()
|
|
}
|
|
}
|
|
|
|
// Actions
|
|
|
|
reset(event) {
|
|
if (this.reverseOrderValue) {
|
|
this.selectLast()
|
|
} else {
|
|
this.selectFirst()
|
|
}
|
|
}
|
|
|
|
navigate(event) {
|
|
this.#keyHandlers[event.key]?.call(this, event)
|
|
this.#relayNavigationToParentNavigableList(event)
|
|
}
|
|
|
|
select({ target }) {
|
|
this.selectItem(target, true)
|
|
}
|
|
|
|
selectCurrentOrReset(event) {
|
|
if (this.currentItem) {
|
|
this.#setCurrentFrom(this.currentItem)
|
|
} else {
|
|
this.reset()
|
|
}
|
|
}
|
|
|
|
selectFirst() {
|
|
this.#setCurrentFrom(this.#visibleItems[0])
|
|
}
|
|
|
|
selectLast() {
|
|
this.#setCurrentFrom(this.#visibleItems[this.#visibleItems.length - 1])
|
|
}
|
|
|
|
deselectWhenClickingOutside(event) {
|
|
if (this.element.contains(event.target)) {
|
|
return
|
|
}
|
|
|
|
this.#clearSelection()
|
|
}
|
|
|
|
// Public
|
|
|
|
async selectItem(item, skipFocus = false) {
|
|
await this.#selectCurrentElementInParent()
|
|
|
|
this.#clearSelection()
|
|
item.setAttribute(this.selectionAttributeValue, "true")
|
|
this.currentItem = item
|
|
this.#refreshActiveDescendant()
|
|
|
|
await nextFrame()
|
|
|
|
if (this.autoScrollValue) { this.currentItem.scrollIntoView({ block: "nearest", inline: "nearest" }) }
|
|
if (this.hasNestedNavigationValue) { this.#activateNestedNavigableList() }
|
|
|
|
if (!skipFocus && this.focusOnSelectionValue) { this.currentItem.focus({ preventScroll: !this.autoScrollValue }) }
|
|
}
|
|
|
|
isSelected(item) {
|
|
return item === this.currentItem
|
|
}
|
|
|
|
// Private
|
|
|
|
async #setCurrentFrom(element) {
|
|
const selectedItem = this.#visibleItems.find(item => item.contains(element))
|
|
|
|
if (selectedItem) {
|
|
await this.selectItem(selectedItem)
|
|
}
|
|
}
|
|
|
|
get #parentNavigableListController() {
|
|
const parentNavigableList = this.element.parentElement?.closest("[data-controller~='navigable-list']")
|
|
if (parentNavigableList) {
|
|
return this.application.getControllerForElementAndIdentifier(parentNavigableList, "navigable-list")
|
|
}
|
|
return null
|
|
}
|
|
|
|
async #selectCurrentElementInParent() {
|
|
const parentController = this.#parentNavigableListController
|
|
if (parentController) {
|
|
const parentItem = this.element.closest("[data-navigable-list-target~='item']")
|
|
const isAlreadySelected = parentController.isSelected(parentItem)
|
|
if (!isAlreadySelected) {
|
|
await parentController.selectItem(parentItem, true)
|
|
}
|
|
}
|
|
}
|
|
|
|
#clearSelection() {
|
|
for (const item of this.itemTargets) {
|
|
item.removeAttribute(this.selectionAttributeValue)
|
|
}
|
|
}
|
|
|
|
#refreshActiveDescendant() {
|
|
const id = this.currentItem?.getAttribute("id")
|
|
if (this.hasInputTarget && id) {
|
|
this.inputTarget.setAttribute("aria-activedescendant", id)
|
|
}
|
|
}
|
|
|
|
#activateNestedNavigableList() {
|
|
const nestedController = this.#nestedNavigableListController()
|
|
if (nestedController) {
|
|
nestedController.selectCurrentOrReset()
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
#nestedNavigableListController() {
|
|
const nestedElement = this.currentItem?.querySelector('[data-controller~="navigable-list"]')
|
|
if (nestedElement) {
|
|
return this.application.getControllerForElementAndIdentifier(nestedElement, "navigable-list")
|
|
}
|
|
return null
|
|
}
|
|
|
|
#activateManualSelection() {
|
|
const preselectedItem = this.itemTargets.find(item => item.hasAttribute(this.selectionAttributeValue))
|
|
if (preselectedItem) {
|
|
this.#setCurrentFrom(preselectedItem)
|
|
}
|
|
}
|
|
|
|
// Stimulus won't let you handle keydown events with different handlers for the same (nested) stimulus controllers.
|
|
#relayNavigationToParentNavigableList(event) {
|
|
const parentController = this.#parentNavigableListController
|
|
if (parentController) {
|
|
parentController.element.focus({ preventScroll: !parentController.autoScrollValue })
|
|
parentController.navigate(event)
|
|
}
|
|
}
|
|
|
|
#selectPrevious() {
|
|
const index = this.#visibleItems.indexOf(this.currentItem)
|
|
if (index > 0) {
|
|
this.#setCurrentFrom(this.#visibleItems[index - 1])
|
|
}
|
|
}
|
|
|
|
#selectNext() {
|
|
const index = this.#visibleItems.indexOf(this.currentItem)
|
|
if (index >= 0 && index < this.#visibleItems.length - 1) {
|
|
this.#setCurrentFrom(this.#visibleItems[index + 1])
|
|
}
|
|
}
|
|
|
|
#handleArrowKey(event, fn) {
|
|
if (event.shiftKey || event.metaKey || event.ctrlKey) { return }
|
|
fn.call()
|
|
if (this.preventHandledKeysValue) {
|
|
event.preventDefault()
|
|
}
|
|
}
|
|
|
|
#clickCurrentItem(event) {
|
|
if (this.actionableItemsValue && this.currentItem && this.#visibleItems.length && this.#isFocusContainedOnNavigableItem) {
|
|
const clickableElement = this.currentItem.querySelector("a,button") || this.currentItem
|
|
clickableElement.click()
|
|
event.preventDefault()
|
|
}
|
|
}
|
|
|
|
get #isFocusContainedOnNavigableItem() {
|
|
return !this.onlyActOnFocusedItemsValue || this.itemTargets.some(item => item === document.activeElement || item.contains(document.activeElement))
|
|
}
|
|
|
|
#toggleCurrentItem(event) {
|
|
if (this.actionableItemsValue && this.currentItem && this.#visibleItems.length) {
|
|
const toggleable = this.currentItem.querySelector("input[type=checkbox]")
|
|
const isDisabled = toggleable.hasAttribute("disabled")
|
|
|
|
if (toggleable) {
|
|
if (!isDisabled) {
|
|
toggleable.checked = !toggleable.checked
|
|
toggleable.dispatchEvent(new Event('change', { bubbles: true }))
|
|
}
|
|
event.preventDefault()
|
|
}
|
|
}
|
|
}
|
|
|
|
get #visibleItems() {
|
|
return this.itemTargets.filter(item => {
|
|
return item.checkVisibility() && !item.hidden
|
|
})
|
|
}
|
|
|
|
#keyHandlers = {
|
|
ArrowDown(event) {
|
|
if (this.supportsVerticalNavigationValue) {
|
|
const selectMethod = this.reverseNavigationValue ? this.#selectPrevious.bind(this) : this.#selectNext.bind(this)
|
|
this.#handleArrowKey(event, selectMethod)
|
|
}
|
|
},
|
|
ArrowUp(event) {
|
|
if (this.supportsVerticalNavigationValue) {
|
|
const selectMethod = this.reverseNavigationValue ? this.#selectNext.bind(this) : this.#selectPrevious.bind(this)
|
|
this.#handleArrowKey(event, selectMethod)
|
|
}
|
|
},
|
|
ArrowRight(event) {
|
|
if (this.supportsHorizontalNavigationValue) {
|
|
this.#handleArrowKey(event, this.#selectNext.bind(this))
|
|
}
|
|
},
|
|
ArrowLeft(event) {
|
|
if (this.supportsHorizontalNavigationValue) {
|
|
this.#handleArrowKey(event, this.#selectPrevious.bind(this))
|
|
}
|
|
},
|
|
Enter(event) {
|
|
// Skip handling during IME composition (e.g., Japanese input)
|
|
if (event.isComposing) { return }
|
|
|
|
if (event.shiftKey) {
|
|
this.#toggleCurrentItem(event)
|
|
} else {
|
|
this.#clickCurrentItem(event)
|
|
}
|
|
},
|
|
}
|
|
}
|