Files
fizzy/app/javascript/controllers/navigable_list_controller.js
T
gijigae e03c002efd Fix Japanese IME input handling for tags and form fields
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.
2025-12-06 17:26:35 +09:00

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)
}
},
}
}