mirror of
https://github.com/CHOMPStation2/CHOMPStation2.git
synced 2025-12-11 18:53:06 +00:00
225 lines
7.3 KiB
TypeScript
225 lines
7.3 KiB
TypeScript
import { Loader } from './common/Loader';
|
|
import { InputButtons } from './common/InputButtons';
|
|
import { Button, Input, Section, Stack } from '../components';
|
|
import { useBackend, useLocalState } from '../backend';
|
|
import { KEY_A, KEY_DOWN, KEY_ESCAPE, KEY_ENTER, KEY_UP, KEY_Z } from '../../common/keycodes';
|
|
import { Window } from '../layouts';
|
|
|
|
type ListInputData = {
|
|
init_value: string;
|
|
items: string[];
|
|
large_buttons: boolean;
|
|
message: string;
|
|
timeout: number;
|
|
title: string;
|
|
};
|
|
|
|
export const ListInputModal = (props, context) => {
|
|
const { act, data } = useBackend<ListInputData>(context);
|
|
const { items = [], message = '', init_value, large_buttons, timeout, title } = data;
|
|
const [selected, setSelected] = useLocalState<number>(context, 'selected', items.indexOf(init_value));
|
|
const [searchBarVisible, setSearchBarVisible] = useLocalState<boolean>(context, 'searchBarVisible', items.length > 9);
|
|
const [searchQuery, setSearchQuery] = useLocalState<string>(context, 'searchQuery', '');
|
|
// 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) {
|
|
setSelected(0);
|
|
document!.getElementById('0')?.scrollIntoView();
|
|
} else {
|
|
setSelected(selected + 1);
|
|
document!.getElementById((selected + 1).toString())?.scrollIntoView();
|
|
}
|
|
} else if (key === KEY_UP) {
|
|
if (selected === null || selected === 0) {
|
|
setSelected(len);
|
|
document!.getElementById(len.toString())?.scrollIntoView();
|
|
} else {
|
|
setSelected(selected - 1);
|
|
document!.getElementById((selected - 1).toString())?.scrollIntoView();
|
|
}
|
|
}
|
|
};
|
|
// User selects an item with mouse
|
|
const onClick = (index: number) => {
|
|
if (index === selected) {
|
|
return;
|
|
}
|
|
setSelected(index);
|
|
};
|
|
// 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) {
|
|
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 = () => {
|
|
setSearchBarVisible(!searchBarVisible);
|
|
setSearchQuery('');
|
|
};
|
|
const filteredItems = items.filter((item) => item?.toLowerCase().includes(searchQuery.toLowerCase()));
|
|
// 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}>
|
|
{timeout && <Loader value={timeout} />}
|
|
<Window.Content
|
|
onKeyDown={(event) => {
|
|
const keyCode = window.event ? event.which : event.keyCode;
|
|
if (keyCode === KEY_DOWN || keyCode === KEY_UP) {
|
|
event.preventDefault();
|
|
onArrowKey(keyCode);
|
|
}
|
|
if (keyCode === KEY_ENTER) {
|
|
event.preventDefault();
|
|
act('submit', { entry: filteredItems[selected] });
|
|
}
|
|
if (!searchBarVisible && keyCode >= KEY_A && keyCode <= KEY_Z) {
|
|
event.preventDefault();
|
|
onLetterSearch(keyCode);
|
|
}
|
|
if (keyCode === KEY_ESCAPE) {
|
|
event.preventDefault();
|
|
act('cancel');
|
|
}
|
|
}}>
|
|
<Section
|
|
buttons={
|
|
<Button
|
|
compact
|
|
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
|
|
title={message}>
|
|
<Stack fill vertical>
|
|
<Stack.Item grow>
|
|
<ListDisplay
|
|
filteredItems={filteredItems}
|
|
onClick={onClick}
|
|
onFocusSearch={onFocusSearch}
|
|
searchBarVisible={searchBarVisible}
|
|
selected={selected}
|
|
/>
|
|
</Stack.Item>
|
|
{searchBarVisible && (
|
|
<SearchBar
|
|
filteredItems={filteredItems}
|
|
onSearch={onSearch}
|
|
searchQuery={searchQuery}
|
|
selected={selected}
|
|
/>
|
|
)}
|
|
<Stack.Item>
|
|
<InputButtons input={filteredItems[selected]} />
|
|
</Stack.Item>
|
|
</Stack>
|
|
</Section>
|
|
</Window.Content>
|
|
</Window>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Displays the list of selectable items.
|
|
* If a search query is provided, filters the items.
|
|
*/
|
|
const ListDisplay = (props, context) => {
|
|
const { act } = useBackend<ListInputData>(context);
|
|
const { filteredItems, onClick, onFocusSearch, searchBarVisible, selected } = props;
|
|
|
|
return (
|
|
<Section fill scrollable tabIndex={0}>
|
|
{filteredItems.map((item, index) => {
|
|
return (
|
|
<Button
|
|
color="transparent"
|
|
fluid
|
|
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 (searchBarVisible && keyCode >= KEY_A && keyCode <= KEY_Z) {
|
|
event.preventDefault();
|
|
onFocusSearch();
|
|
}
|
|
}}
|
|
selected={index === selected}
|
|
style={{
|
|
'animation': 'none',
|
|
'transition': 'none',
|
|
}}>
|
|
{item.replace(/^\w/, (c) => c.toUpperCase())}
|
|
</Button>
|
|
);
|
|
})}
|
|
</Section>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Renders a search bar input.
|
|
* Closing the bar defaults input to an empty string.
|
|
*/
|
|
const SearchBar = (props, context) => {
|
|
const { act } = useBackend<ListInputData>(context);
|
|
const { filteredItems, onSearch, searchQuery, selected } = props;
|
|
|
|
return (
|
|
<Input
|
|
autoFocus
|
|
autoSelect
|
|
fluid
|
|
onEnter={(event) => {
|
|
event.preventDefault();
|
|
act('submit', { entry: filteredItems[selected] });
|
|
}}
|
|
onInput={(_, value) => onSearch(value)}
|
|
placeholder="Search..."
|
|
value={searchQuery}
|
|
/>
|
|
);
|
|
};
|