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

[FIX/FEATURE] Make virtual keyboard compatible with all inputs #3961

Open
wants to merge 1 commit 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
1 change: 0 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,5 @@
</head>
<body>
<div id="root"></div>
<div class="simple-keyboard"></div>
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was not used, we have another div for this inside the react app

</body>
</html>
4 changes: 2 additions & 2 deletions src/frontend/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,12 @@ body {
}

/* Disable content behind an opened dialog so it cannot be clicked */
#root:has(dialog:popover-open) {
body:has(dialog[open]) {
pointer-events: none;

/* Make sure content of the dialog and Help can be clicked */
.HelpButton,
:popover-open {
dialog[open] {
pointer-events: all;
}
}
Expand Down
4 changes: 3 additions & 1 deletion src/frontend/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,9 @@ function Root() {
</main>
<div className="controller">
<ControllerHints />
<div className="simple-keyboard"></div>
<dialog className="simple-keyboard-wrapper">
<div className="simple-keyboard"></div>
</dialog>
</div>
{showOverlayControls && <WindowControls />}
{experimentalFeatures.enableHelp && <Help items={help.items} />}
Expand Down
24 changes: 7 additions & 17 deletions src/frontend/components/UI/Dialog/components/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,27 +50,20 @@ export const Dialog: React.FC<DialogProps> = ({
}
dialog.addEventListener('cancel', cancel)

if (disableDialogBackdropClose) {
dialog['showPopover']()
dialog.showModal()

return () => {
dialog.removeEventListener('cancel', cancel)
dialog['hidePopover']()
}
} else {
dialog.showModal()

return () => {
dialog.removeEventListener('cancel', cancel)
dialog.close()
}
return () => {
dialog.removeEventListener('cancel', cancel)
dialog.close()
}
}
return
}, [dialogRef.current, disableDialogBackdropClose])

const onDialogClick = useCallback(
(e: SyntheticEvent) => {
if (disableDialogBackdropClose) return

if (e.target === dialogRef.current) {
const ev = e.nativeEvent as MouseEvent
const tg = e.target as HTMLElement
Expand All @@ -84,7 +77,7 @@ export const Dialog: React.FC<DialogProps> = ({
}
}
},
[onClose]
[onClose, disableDialogBackdropClose]
)

const closeIfEsc = (event: KeyboardEvent<HTMLDialogElement>) => {
Expand All @@ -99,9 +92,6 @@ export const Dialog: React.FC<DialogProps> = ({
className={`Dialog__element ${className}`}
ref={dialogRef}
onClick={onDialogClick}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore, this feature is new and not yet typed
popover="manual"
onKeyUp={closeIfEsc}
>
{showCloseButton && (
Expand Down
2 changes: 1 addition & 1 deletion src/frontend/components/UI/PathSelectionBox/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ const PathSelectionBox = ({
return (
<TextInputWithIconField
value={tmpPath}
onChange={(e) => setTmpPath(e.target.value)}
onChange={(newVal) => setTmpPath(newVal)}
onBlur={(e) => onPathChange(e.target.value)}
onIconClick={handleIconClick}
placeholder={placeholder}
Expand Down
36 changes: 33 additions & 3 deletions src/frontend/components/UI/TextInputField/index.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import React, { ReactNode, useContext } from 'react'
import React, { ReactNode, useContext, useEffect, useRef } from 'react'
import classnames from 'classnames'
import ContextProvider from 'frontend/state/ContextProvider'
import './index.css'

interface TextInputFieldProps
extends React.InputHTMLAttributes<HTMLInputElement> {
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
htmlId: string
inputIcon?: ReactNode
afterInput?: ReactNode
label?: string
placeholder?: string
extraClass?: string
warning?: ReactNode
value: string
onChange: (newValue: string) => void
}

const TextInputField = ({
Expand All @@ -22,9 +24,28 @@ const TextInputField = ({
afterInput,
warning,
value,
onChange,
...inputProps
}: TextInputFieldProps) => {
const { isRTL } = useContext(ContextProvider)
const input = useRef<HTMLInputElement>(null)

// we have to use an event listener instead of the react
// onChange callback so it works with the virtual keyboard
useEffect(() => {
if (input.current) {
const element = input.current
element.value = value
const handler = () => {
onChange(element.value)
}
element.addEventListener('input', handler)
return () => {
element.removeEventListener('input', handler)
}
}
return
}, [input])

return (
<div
Expand All @@ -34,7 +55,16 @@ const TextInputField = ({
>
{label && <label htmlFor={htmlId}>{label}</label>}
{inputIcon}
<input type="text" id={htmlId} value={value} {...inputProps} />
<input
type="text"
id={htmlId}
value={value}
ref={input}
{...inputProps}
// passing this dummy onChange function to avoid a React warning
// we are handling the change with the eventListener above
onChange={() => {}}
/>
{value && warning}
{afterInput}
</div>
Expand Down
4 changes: 2 additions & 2 deletions src/frontend/components/UI/TextInputWithIconField/index.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import React, { ChangeEvent, FocusEvent, ReactNode } from 'react'
import React, { FocusEvent, ReactNode } from 'react'
import TextInputField from '../TextInputField'
import SvgButton from '../SvgButton'

interface TextInputWithIconFieldProps {
htmlId: string
value: string
onChange: (event: ChangeEvent<HTMLInputElement>) => void
onChange: (newValue: string) => void
icon: JSX.Element
onIconClick: () => void
afterInput?: ReactNode
Expand Down
8 changes: 4 additions & 4 deletions src/frontend/components/UI/TwoColTableInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,8 +181,8 @@ export function TableInput({
htmlId={`${header.key}-key`}
placeholder={inputPlaceHolder.key}
extraClass={keyError ? 'error' : ''}
onChange={(event) => {
setValueInputs({ ...valueInputs, key: event.target.value })
onChange={(newValue) => {
setValueInputs({ ...valueInputs, key: newValue })
}}
/>
</td>
Expand All @@ -193,8 +193,8 @@ export function TableInput({
value={valueInputs.value}
htmlId={`${header.value}-key`}
placeholder={inputPlaceHolder.value}
onChange={(event) => {
setValueInputs({ ...valueInputs, value: event.target.value })
onChange={(newValue) => {
setValueInputs({ ...valueInputs, value: newValue })
}}
/>
</td>
Expand Down
20 changes: 13 additions & 7 deletions src/frontend/helpers/gamepad.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,8 @@ export const initGamepad = () => {
} else if (VirtualKeyboardController.isButtonFocused()) {
// simulate a left click on a virtual keyboard button
action = 'leftClick'
} else if (isSearchInput()) {
// open virtual keyboard if focusing the search input
} else if (isTextInput()) {
// open virtual keyboard if focusing a text input
VirtualKeyboardController.initOrFocus()
return
}
Expand Down Expand Up @@ -183,7 +183,15 @@ export const initGamepad = () => {
}
}

const currentElement = () => document.querySelector<HTMLElement>(':focus')
const currentElement = () => {
const el = document.querySelector<HTMLElement>(':focus')
if (!el) return

// we can't call `click` in svg elements, so we use its parent
if (el.tagName === 'svg') return el.parentElement

return el
}

const shouldSimulateClick = isSelect
function isSelect() {
Expand All @@ -193,13 +201,11 @@ export const initGamepad = () => {
return el.tagName === 'SELECT'
}

function isSearchInput() {
function isTextInput() {
const el = currentElement()
if (!el) return false

// only change this if you change the id of the input element
// in frontend/components/UI/SearchBar/index.tsx
return el.id === 'search'
return el.tagName === 'INPUT' && (el as HTMLInputElement).type === 'text'
}

function isGameCard() {
Expand Down
51 changes: 33 additions & 18 deletions src/frontend/helpers/virtualKeyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,47 +2,61 @@ import Keyboard from 'simple-keyboard'
import 'simple-keyboard/build/css/index.css'

let virtualKeyboard: Keyboard | null = null
let targetInput: HTMLInputElement | null = null

function currentElement() {
return document.querySelector<HTMLElement>(':focus')
}

function searchInput() {
// only change this if you change the id of the input element
// in frontend/components/UI/SearchBar/index.tsx
return document.querySelector<HTMLInputElement>('#search')
}

function focusKeyboard() {
const firstButton = document.querySelector<HTMLElement>(
'.hg-button[data-skbtn="h"]'
)
firstButton?.focus()
}

function typeInSearchInput(button: string) {
const input = searchInput()
if (!input) return
function typeInInput(button: string) {
if (!targetInput) return

if (button.length === 1) {
input.value = input.value + button
targetInput.value = targetInput.value + button
} else if (button === '{bksp}') {
if (input.value.length > 0) {
input.value = input.value.slice(0, -1)
if (targetInput.value.length > 0) {
targetInput.value = targetInput.value.slice(0, -1)
}
} else if (button === '{space}') {
input.value = input.value + ' '
targetInput.value = targetInput.value + ' '
}
input.dispatchEvent(new Event('input'))
targetInput.dispatchEvent(new Event('input'))
}

function makeKeyboardTopLayer() {
const wrapper = document.querySelector(
'.simple-keyboard-wrapper'
) as HTMLDialogElement
wrapper.showModal()
}

function closeKeyboardTopLayer() {
const wrapper = document.querySelector(
'.simple-keyboard-wrapper'
) as HTMLDialogElement
wrapper.close()
}

export const VirtualKeyboardController = {
initOrFocus: () => {
const el = currentElement()
if (!el) return

targetInput = el as HTMLInputElement

if (!virtualKeyboard) {
virtualKeyboard = new Keyboard({
onKeyPress: (button: string) => typeInSearchInput(button),
onKeyPress: (button: string) => typeInInput(button),
onRender: () => focusKeyboard()
})
makeKeyboardTopLayer()
} else {
focusKeyboard()
}
Expand All @@ -56,13 +70,14 @@ export const VirtualKeyboardController = {
isActive: () => virtualKeyboard !== null,
destroy: () => {
if (virtualKeyboard) virtualKeyboard.destroy()
closeKeyboardTopLayer()
virtualKeyboard = null
searchInput()?.focus()
targetInput?.focus()
},
backspace: () => {
typeInSearchInput('{bksp}')
typeInInput('{bksp}')
},
space: () => {
typeInSearchInput(' ')
typeInInput(' ')
}
}
19 changes: 11 additions & 8 deletions src/frontend/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ body {
// these 2 !important fix a problem when displaying the
// context menu in the library, material ui sets values
// that create problems, don't change
overflow: visible !important;
overflow: auto !important;
padding: 0 !important;
}

Expand Down Expand Up @@ -80,13 +80,16 @@ body {
background: var(--gradient-body-background, var(--body-background));
}

.simple-keyboard {
position: absolute;
z-index: 10;
bottom: 100%;
left: 0px;
right: 0px;
background: var(--osk-background);
.simple-keyboard-wrapper {
display: none;
&[open] {
position: fixed;
display: initial;
inset-block-start: auto;
inset-block-end: 50px;
width: 100vw;
background: var(--osk-background);
}
}

.smallInputInfo {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ function CategoryItem({
<TextInputField
htmlId={`edit-${name.replace(' ', '-')}`}
value={newName}
onChange={(e) => setNewName(e.target.value)}
onChange={(newValue) => setNewName(newValue)}
label={t('categories-manager.rename', 'Rename "{{name}}"', { name })}
/>
)}
Expand Down Expand Up @@ -201,7 +201,7 @@ function CategoriesManager() {
<TextInputField
htmlId="new-category-name"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
onChange={(newValue) => setNewCategoryName(newValue)}
placeholder={t(
'categories-manager.add-placeholder',
'Add new category'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export default function BranchSelector({
htmlId="private-branch-password-input"
value={branchPassword}
type={'password'}
onChange={(e) => setBranchPassword(e.target.value)}
onChange={(newValue) => setBranchPassword(newValue)}
placeholder={t(
'game.branch.password',
'Set private channel password'
Expand Down
Loading
Loading