mirror of
https://github.com/Bubberstation/Bubberstation.git
synced 2026-08-30 16:47:13 +01:00
Tgui list input hotfix + changes (#63515)
Previous issues addressed: - You could not reselect search just by keying - Pressing enter sometimes deselected - Double click would deselect - which was intentional, but useless really - Default values were not implemented in original - Validation is required for tgui inputs but truly only one "needs" it, it costs performance otherwise Co-authored-by: Mothblocks <35135081+Mothblocks@users.noreply.github.com>
This commit is contained in:
@@ -7,9 +7,10 @@
|
||||
* * message - The content of the input box, shown in the body of the TGUI window.
|
||||
* * title - The title of the input box, shown on the top of the TGUI window.
|
||||
* * items - The options that can be chosen by the user, each string is assigned a button on the UI.
|
||||
* * default - If an option is already preselected on the UI. Current values, etc.
|
||||
* * timeout - The timeout of the input box, after which the menu will close and qdel itself. Set to zero for no timeout.
|
||||
*/
|
||||
/proc/tgui_input_list(mob/user, message, title = "Select", list/items, timeout = 0)
|
||||
/proc/tgui_input_list(mob/user, message, title = "Select", list/items, default, timeout = 0)
|
||||
if (!user)
|
||||
user = usr
|
||||
if(!length(items))
|
||||
@@ -23,7 +24,7 @@
|
||||
/// Client does NOT have tgui_input on: Returns regular input
|
||||
if(!user.client.prefs.read_preference(/datum/preference/toggle/tgui_input))
|
||||
return input(user, message, title) as null|anything in items
|
||||
var/datum/tgui_list_input/input = new(user, message, title, items, timeout)
|
||||
var/datum/tgui_list_input/input = new(user, message, title, items, default, timeout)
|
||||
input.ui_interact(user)
|
||||
input.wait()
|
||||
if (input)
|
||||
@@ -39,10 +40,11 @@
|
||||
* * message - The content of the input box, shown in the body of the TGUI window.
|
||||
* * title - The title of the input box, shown on the top of the TGUI window.
|
||||
* * items - The options that can be chosen by the user, each string is assigned a button on the UI.
|
||||
* * default - If an option is already preselected on the UI. Current values, etc.
|
||||
* * callback - The callback to be invoked when a choice is made.
|
||||
* * timeout - The timeout of the input box, after which the menu will close and qdel itself. Set to zero for no timeout.
|
||||
*/
|
||||
/proc/tgui_input_list_async(mob/user, message, title, list/items, datum/callback/callback, timeout = 60 SECONDS)
|
||||
/proc/tgui_input_list_async(mob/user, message, title = "Select", list/items, default, datum/callback/callback, timeout = 60 SECONDS)
|
||||
if (!user)
|
||||
user = usr
|
||||
if(!length(items))
|
||||
@@ -53,7 +55,10 @@
|
||||
user = client.mob
|
||||
else
|
||||
return
|
||||
var/datum/tgui_list_input/async/input = new(user, message, title, items, callback, timeout)
|
||||
/// Client does NOT have tgui_input on: Returns regular input
|
||||
if(!user.client.prefs.read_preference(/datum/preference/toggle/tgui_input))
|
||||
return input(user, message, title) as null|anything in items
|
||||
var/datum/tgui_list_input/async/input = new(user, message, title, items, default, callback, timeout)
|
||||
input.ui_interact(user)
|
||||
|
||||
/**
|
||||
@@ -73,6 +78,8 @@
|
||||
var/list/items_map
|
||||
/// The button that the user has pressed, null if no selection has been made
|
||||
var/choice
|
||||
/// The default button to be selected
|
||||
var/default
|
||||
/// The time at which the tgui_list_input was created, for displaying timeout progress.
|
||||
var/start_time
|
||||
/// The lifespan of the tgui_list_input, after which the window will close and delete itself.
|
||||
@@ -80,11 +87,12 @@
|
||||
/// Boolean field describing if the tgui_list_input was closed by the user.
|
||||
var/closed
|
||||
|
||||
/datum/tgui_list_input/New(mob/user, message, title, list/items, timeout)
|
||||
/datum/tgui_list_input/New(mob/user, message, title, list/items, default, timeout)
|
||||
src.title = title
|
||||
src.message = message
|
||||
src.items = list()
|
||||
src.items_map = list()
|
||||
src.default = default
|
||||
var/list/repeat_items = list()
|
||||
|
||||
// Gets rid of illegal characters
|
||||
@@ -136,6 +144,7 @@
|
||||
|
||||
/datum/tgui_list_input/ui_static_data(mob/user)
|
||||
. = list(
|
||||
"init_value" = default || items[1],
|
||||
"items" = items,
|
||||
"message" = message,
|
||||
"preferences" = list(),
|
||||
@@ -177,8 +186,8 @@
|
||||
/// The callback to be invoked by the tgui_list_input upon having a choice made.
|
||||
var/datum/callback/callback
|
||||
|
||||
/datum/tgui_list_input/async/New(mob/user, message, title, list/items, callback, timeout)
|
||||
..(user, message, title, items, timeout)
|
||||
/datum/tgui_list_input/async/New(mob/user, message, title, list/items, default, callback, timeout)
|
||||
..(user, message, title, items, default, timeout)
|
||||
src.callback = callback
|
||||
|
||||
/datum/tgui_list_input/async/Destroy(force, ...)
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* * min_value - Specifies a minimum value. Often 0.
|
||||
* * timeout - The timeout of the number input, after which the modal will close and qdel itself. Set to zero for no timeout.
|
||||
*/
|
||||
/proc/tgui_input_number(mob/user, message = null, title = "Number Input", default = null, max_value = null, min_value = 0, timeout = 0)
|
||||
/proc/tgui_input_number(mob/user, message, title = "Number Input", default, max_value, min_value, timeout = 0)
|
||||
if (!user)
|
||||
user = usr
|
||||
if (!istype(user))
|
||||
@@ -23,7 +23,7 @@
|
||||
user = client.mob
|
||||
else
|
||||
return
|
||||
/// Client does NOT have tgui_input on: Returns regular input
|
||||
// Client does NOT have tgui_input on: Returns regular input
|
||||
if(!user.client.prefs.read_preference(/datum/preference/toggle/tgui_input))
|
||||
return input(user, message, title, default) as null|num
|
||||
var/datum/tgui_input_number/number_input = new(user, message, title, default, max_value, min_value, timeout)
|
||||
@@ -48,7 +48,7 @@
|
||||
* * callback - The callback to be invoked when a choice is made.
|
||||
* * timeout - The timeout of the number input, after which the modal will close and qdel itself. Set to zero for no timeout.
|
||||
*/
|
||||
/proc/tgui_input_number_async(mob/user, message = null, title = "Number Input", default = null, max_value = null, min_value = 0, datum/callback/callback, timeout = 60 SECONDS)
|
||||
/proc/tgui_input_number_async(mob/user, message, title = "Number Input", default, max_value, min_value, datum/callback/callback, timeout = 60 SECONDS)
|
||||
if (!user)
|
||||
user = usr
|
||||
if (!istype(user))
|
||||
@@ -57,6 +57,9 @@
|
||||
user = client.mob
|
||||
else
|
||||
return
|
||||
// Client does NOT have tgui_input on: Returns regular input
|
||||
if(!user.client.prefs.read_preference(/datum/preference/toggle/tgui_input))
|
||||
return input(user, message, title, default) as null|num
|
||||
var/datum/tgui_input_number/async/number_input = new(user, message, title, default, max_value, min_value, callback, timeout)
|
||||
number_input.ui_interact(user)
|
||||
|
||||
@@ -126,10 +129,10 @@
|
||||
|
||||
/datum/tgui_input_number/ui_static_data(mob/user)
|
||||
. = list(
|
||||
"init_value" = default || 0, // Default is a reserved keyword
|
||||
"max_value" = max_value,
|
||||
"message" = message,
|
||||
"min_value" = min_value,
|
||||
"placeholder" = default, /// You cannot use default as a const
|
||||
"min_value" = min_value || 0,
|
||||
"preferences" = list(),
|
||||
"title" = title
|
||||
)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Loader } from './common/Loader';
|
||||
import { InputButtons, Preferences, Validator } from './common/InputButtons';
|
||||
import { InputButtons, Preferences } from './common/InputButtons';
|
||||
import { Button, Input, Section, Stack } from '../components';
|
||||
import { KEY_ENTER, KEY_DOWN, KEY_UP, KEY_ESCAPE } from '../../common/keycodes';
|
||||
import { KEY_A, KEY_DOWN, KEY_ESCAPE, KEY_ENTER, KEY_UP, KEY_Z } from '../../common/keycodes';
|
||||
import { Window } from '../layouts';
|
||||
import { useBackend, useLocalState } from '../backend';
|
||||
|
||||
type ListInputData = {
|
||||
items: string[];
|
||||
message: string;
|
||||
init_value: string;
|
||||
preferences: Preferences;
|
||||
timeout: number;
|
||||
title: string;
|
||||
@@ -15,12 +16,12 @@ type ListInputData = {
|
||||
|
||||
export const ListInputModal = (_, context) => {
|
||||
const { act, data } = useBackend<ListInputData>(context);
|
||||
const { items = [], message, preferences, timeout, title } = data;
|
||||
const { items = [], message, init_value, preferences, timeout, title } = data;
|
||||
const { large_buttons } = preferences;
|
||||
const [selected, setSelected] = useLocalState<number | null>(
|
||||
const [selected, setSelected] = useLocalState<number>(
|
||||
context,
|
||||
'selected',
|
||||
0
|
||||
items.indexOf(init_value)
|
||||
);
|
||||
const [searchBarVisible, setSearchBarVisible] = useLocalState<boolean>(
|
||||
context,
|
||||
@@ -32,56 +33,60 @@ export const ListInputModal = (_, context) => {
|
||||
'searchQuery',
|
||||
''
|
||||
);
|
||||
const [inputIsValid, setInputIsValid] = useLocalState<Validator>(
|
||||
context,
|
||||
'inputIsValid',
|
||||
{ isValid: true, error: null }
|
||||
);
|
||||
// User presses up or down on keyboard
|
||||
// Simulates clicking an item
|
||||
const onArrowKey = (key: number) => {
|
||||
const len = filteredItems.length - 1;
|
||||
if (key === KEY_DOWN) {
|
||||
if (selected === null || selected === len) {
|
||||
onClick(0);
|
||||
setSelected(0);
|
||||
document!.getElementById('0')?.scrollIntoView();
|
||||
} else {
|
||||
onClick(selected + 1);
|
||||
setSelected(selected + 1);
|
||||
document!.getElementById((selected + 1).toString())?.scrollIntoView();
|
||||
}
|
||||
} else if (key === KEY_UP) {
|
||||
if (selected === null || selected === 0) {
|
||||
onClick(len);
|
||||
setSelected(len);
|
||||
document!.getElementById(len.toString())?.scrollIntoView();
|
||||
} else {
|
||||
onClick(selected - 1);
|
||||
setSelected(selected - 1);
|
||||
document!.getElementById((selected - 1).toString())?.scrollIntoView();
|
||||
}
|
||||
}
|
||||
};
|
||||
// User selects an item with mouse
|
||||
const onClick = (index: number) => {
|
||||
if (isNaN(index) || index === selected) {
|
||||
setInputIsValid({ isValid: false, error: 'No selection' });
|
||||
setSelected(null);
|
||||
} else {
|
||||
setInputIsValid({ isValid: true, error: null });
|
||||
setSelected(index);
|
||||
document!.getElementById(index.toString())?.focus();
|
||||
if (index === selected) {
|
||||
return;
|
||||
}
|
||||
setSelected(index);
|
||||
};
|
||||
// User doesn't have search bar visible & presses a key
|
||||
const onLetterKey = (key: number) => {
|
||||
// User presses a letter key and searchbar is visible
|
||||
const onFocusSearch = () => {
|
||||
setSearchBarVisible(false);
|
||||
setSearchBarVisible(true);
|
||||
};
|
||||
// User presses a letter key with no searchbar visible
|
||||
const onLetterSearch = (key: number) => {
|
||||
const keyChar = String.fromCharCode(key);
|
||||
const foundItem = items.find((item) => {
|
||||
return item?.toLowerCase().startsWith(keyChar?.toLowerCase());
|
||||
});
|
||||
if (foundItem) {
|
||||
setSelected(filteredItems.indexOf(foundItem));
|
||||
document!.getElementById(filteredItems
|
||||
.indexOf(foundItem)
|
||||
.toString())?.focus();
|
||||
const foundIndex = items.indexOf(foundItem);
|
||||
setSelected(foundIndex);
|
||||
document!.getElementById(foundIndex.toString())?.scrollIntoView();
|
||||
}
|
||||
};
|
||||
// User types into search bar
|
||||
const onSearch = (query: string) => {
|
||||
if (query === searchQuery) {
|
||||
return;
|
||||
}
|
||||
setSearchQuery(query);
|
||||
setSelected(0);
|
||||
document!.getElementById('0')?.scrollIntoView();
|
||||
};
|
||||
// User presses the search button
|
||||
const onSearchBarToggle = () => {
|
||||
@@ -94,6 +99,10 @@ export const ListInputModal = (_, context) => {
|
||||
// Dynamically changes the window height based on the message.
|
||||
const windowHeight
|
||||
= 325 + Math.ceil(message?.length / 3) + (large_buttons ? 5 : 0);
|
||||
// Grabs the cursor when no search bar is visible.
|
||||
if (!searchBarVisible) {
|
||||
setTimeout(() => document!.getElementById(selected.toString())?.focus(), 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<Window title={title} width={325} height={windowHeight}>
|
||||
@@ -105,9 +114,13 @@ export const ListInputModal = (_, context) => {
|
||||
event.preventDefault();
|
||||
onArrowKey(keyCode);
|
||||
}
|
||||
if (!searchBarVisible && keyCode >= 65 && keyCode <= 90) {
|
||||
if (keyCode === KEY_ENTER) {
|
||||
event.preventDefault();
|
||||
onLetterKey(keyCode);
|
||||
act('submit', { entry: filteredItems[selected] });
|
||||
}
|
||||
if (!searchBarVisible && keyCode >= KEY_A && keyCode <= KEY_Z) {
|
||||
event.preventDefault();
|
||||
onLetterSearch(keyCode);
|
||||
}
|
||||
if (keyCode === KEY_ESCAPE) {
|
||||
event.preventDefault();
|
||||
@@ -118,13 +131,15 @@ export const ListInputModal = (_, context) => {
|
||||
buttons={
|
||||
<Button
|
||||
compact
|
||||
icon="search"
|
||||
color="transparent"
|
||||
selected={searchBarVisible}
|
||||
tooltip="Search Bar"
|
||||
icon={searchBarVisible ? "search" : "font"}
|
||||
selected
|
||||
tooltip={searchBarVisible
|
||||
? "Search Mode. Type to search or use arrow keys to select manually."
|
||||
: "Hotkey Mode. Type a letter to jump to the first match. Enter to select."}
|
||||
tooltipPosition="left"
|
||||
onClick={() => onSearchBarToggle()}
|
||||
/>
|
||||
|
||||
}
|
||||
className="ListInput__Section"
|
||||
fill
|
||||
@@ -134,15 +149,21 @@ export const ListInputModal = (_, context) => {
|
||||
<ListDisplay
|
||||
filteredItems={filteredItems}
|
||||
onClick={onClick}
|
||||
isValid={inputIsValid.isValid}
|
||||
onFocusSearch={onFocusSearch}
|
||||
searchBarVisible={searchBarVisible}
|
||||
selected={selected}
|
||||
/>
|
||||
</Stack.Item>
|
||||
{searchBarVisible && <SearchBar onSearch={onSearch} />}
|
||||
{searchBarVisible && (
|
||||
<SearchBar
|
||||
filteredItems={filteredItems}
|
||||
onSearch={onSearch}
|
||||
searchQuery={searchQuery}
|
||||
selected={selected}
|
||||
/>
|
||||
)}
|
||||
<Stack.Item pl={!large_buttons && 4} pr={!large_buttons && 4}>
|
||||
<InputButtons
|
||||
input={selected !== null ? filteredItems[selected] : null}
|
||||
inputIsValid={inputIsValid} />
|
||||
<InputButtons input={filteredItems[selected]} />
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
</Section>
|
||||
@@ -157,7 +178,8 @@ export const ListInputModal = (_, context) => {
|
||||
*/
|
||||
const ListDisplay = (props, context) => {
|
||||
const { act } = useBackend<ListInputData>(context);
|
||||
const { filteredItems, isValid, onClick, selected } = props;
|
||||
const { filteredItems, onClick, onFocusSearch, searchBarVisible, selected }
|
||||
= props;
|
||||
|
||||
return (
|
||||
<Section fill scrollable tabIndex={0}>
|
||||
@@ -169,11 +191,15 @@ const ListDisplay = (props, context) => {
|
||||
id={index}
|
||||
key={index}
|
||||
onClick={() => onClick(index)}
|
||||
onDblClick={(event) => {
|
||||
event.preventDefault();
|
||||
act('submit', { entry: filteredItems[selected] });
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
const keyCode = window.event ? event.which : event.keyCode;
|
||||
if (keyCode === KEY_ENTER && isValid) {
|
||||
if (searchBarVisible && keyCode >= KEY_A && keyCode <= KEY_Z) {
|
||||
event.preventDefault();
|
||||
act('submit', { entry: filteredItems[selected] });
|
||||
onFocusSearch();
|
||||
}
|
||||
}}
|
||||
selected={index === selected}
|
||||
@@ -193,16 +219,20 @@ const ListDisplay = (props, context) => {
|
||||
* Renders a search bar input.
|
||||
* Closing the bar defaults input to an empty string.
|
||||
*/
|
||||
const SearchBar = (props) => {
|
||||
const { onSearch, searchQuery } = props;
|
||||
const SearchBar = (props, context) => {
|
||||
const { act } = useBackend<ListInputData>(context);
|
||||
const { filteredItems, onSearch, searchQuery, selected } = props;
|
||||
|
||||
return (
|
||||
<Input
|
||||
autoFocus
|
||||
autoSelect
|
||||
fluid
|
||||
onInput={(_, value) => {
|
||||
onSearch(value);
|
||||
onEnter={(event) => {
|
||||
event.preventDefault();
|
||||
act('submit', { entry: filteredItems[selected] });
|
||||
}}
|
||||
onInput={(_, value) => onSearch(value)}
|
||||
placeholder="Search..."
|
||||
value={searchQuery}
|
||||
/>
|
||||
|
||||
@@ -9,7 +9,7 @@ type NumberInputData = {
|
||||
max_value: number | null;
|
||||
message: string;
|
||||
min_value: number | null;
|
||||
placeholder: number;
|
||||
init_value: number;
|
||||
preferences: Preferences;
|
||||
timeout: number;
|
||||
title: string;
|
||||
@@ -17,17 +17,15 @@ type NumberInputData = {
|
||||
|
||||
export const NumberInputModal = (_, context) => {
|
||||
const { act, data } = useBackend<NumberInputData>(context);
|
||||
const { message, placeholder, preferences, timeout, title } = data;
|
||||
const { message, init_value, preferences, timeout, title } = data;
|
||||
const { large_buttons } = preferences;
|
||||
const [input, setInput] = useLocalState(context, 'input', placeholder);
|
||||
const [input, setInput] = useLocalState(context, 'input', init_value);
|
||||
const onChange = (value: number) => {
|
||||
setInput(value);
|
||||
};
|
||||
const onClick = (value: number) => {
|
||||
setInput(value);
|
||||
};
|
||||
// NumberInput basically handles everything here
|
||||
const defaultValidState = { isValid: true, error: null };
|
||||
// Dynamically changes the window height based on the message.
|
||||
const windowHeight
|
||||
= 125 + Math.ceil(message?.length / 3) + (large_buttons ? 5 : 0);
|
||||
@@ -54,7 +52,7 @@ export const NumberInputModal = (_, context) => {
|
||||
<InputArea input={input} onClick={onClick} onChange={onChange} />
|
||||
</Stack.Item>
|
||||
<Stack.Item pl={!large_buttons && 4} pr={!large_buttons && 4}>
|
||||
<InputButtons input={input} inputIsValid={defaultValidState} />
|
||||
<InputButtons input={input} />
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
</Section>
|
||||
@@ -65,8 +63,8 @@ export const NumberInputModal = (_, context) => {
|
||||
|
||||
/** Gets the user input and invalidates if there's a constraint. */
|
||||
const InputArea = (props, context) => {
|
||||
const { act, data } = useBackend<NumberInputData>(context);
|
||||
const { min_value, max_value, placeholder } = data;
|
||||
const { data } = useBackend<NumberInputData>(context);
|
||||
const { min_value, max_value, init_value } = data;
|
||||
const { input, onClick, onChange } = props;
|
||||
|
||||
return (
|
||||
@@ -87,7 +85,7 @@ const InputArea = (props, context) => {
|
||||
maxValue={max_value}
|
||||
onChange={(_, value) => onChange(value)}
|
||||
onDrag={(_, value) => onChange(value)}
|
||||
value={input || placeholder || 0}
|
||||
value={input || init_value || 0}
|
||||
/>
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
@@ -100,7 +98,7 @@ const InputArea = (props, context) => {
|
||||
<Stack.Item>
|
||||
<Button
|
||||
icon="redo"
|
||||
onClick={() => onClick(placeholder || 0)}
|
||||
onClick={() => onClick(init_value || 0)}
|
||||
tooltip="Reset"
|
||||
/>
|
||||
</Stack.Item>
|
||||
|
||||
@@ -7,7 +7,7 @@ type InputButtonsData = {
|
||||
|
||||
type InputButtonsProps = {
|
||||
input: string | number | null;
|
||||
inputIsValid: Validator;
|
||||
inputIsValid?: Validator;
|
||||
};
|
||||
|
||||
export type Validator = {
|
||||
@@ -24,17 +24,17 @@ export const InputButtons = (props: InputButtonsProps, context) => {
|
||||
const { act, data } = useBackend<InputButtonsData>(context);
|
||||
const { large_buttons = false, swapped_buttons = true } = data.preferences;
|
||||
const { input, inputIsValid } = props;
|
||||
const { isValid, error } = inputIsValid;
|
||||
|
||||
const submitButton = (
|
||||
<Button
|
||||
color="good"
|
||||
disabled={!isValid}
|
||||
disabled={inputIsValid && !inputIsValid.isValid}
|
||||
fluid={!!large_buttons}
|
||||
height={!!large_buttons && 2}
|
||||
onClick={() => act('submit', { entry: input })}
|
||||
pt={large_buttons ? 0.33 : 0}
|
||||
textAlign="center"
|
||||
tooltip={!!large_buttons && error}
|
||||
tooltip={(!!large_buttons && inputIsValid?.error) || null}
|
||||
width={!large_buttons && 6}>
|
||||
{large_buttons ? 'SUBMIT' : 'Submit'}
|
||||
</Button>
|
||||
@@ -63,9 +63,9 @@ export const InputButtons = (props: InputButtonsProps, context) => {
|
||||
)}
|
||||
{!large_buttons && (
|
||||
<Stack.Item grow>
|
||||
{!isValid && (
|
||||
{inputIsValid && !inputIsValid.isValid && inputIsValid.error && (
|
||||
<Box color="average" nowrap textAlign="center">
|
||||
{error}
|
||||
{inputIsValid.error}
|
||||
</Box>
|
||||
)}
|
||||
</Stack.Item>
|
||||
|
||||
Reference in New Issue
Block a user