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

[MIRROR] Makes dropdowns better #2986

Merged
merged 3 commits into from
Apr 21, 2024
Merged
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
21 changes: 19 additions & 2 deletions code/modules/antagonists/malf_ai/malf_ai_modules.dm
Original file line number Diff line number Diff line change
Expand Up @@ -945,6 +945,8 @@ GLOBAL_LIST_INIT(malf_modules, subtypesof(/datum/ai_module))
var/prev_verbs
/// Saved span state, used to restore after a voice change
var/prev_span
/// The list of available voices
var/static/list/voice_options = list("normal", SPAN_ROBOT, SPAN_YELL, SPAN_CLOWN)

/obj/machinery/ai_voicechanger/Initialize(mapload)
. = ..()
Expand Down Expand Up @@ -972,11 +974,12 @@ GLOBAL_LIST_INIT(malf_modules, subtypesof(/datum/ai_module))

/obj/machinery/ai_voicechanger/ui_data(mob/user)
var/list/data = list()
data["voices"] = list("normal", SPAN_ROBOT, SPAN_YELL, SPAN_CLOWN) //manually adding this since i dont see other option
data["voices"] = voice_options
data["loud"] = loudvoice
data["on"] = changing_voice
data["say_verb"] = say_verb
data["name"] = say_name
data["selected"] = say_span || owner.speech_span
return data

/obj/machinery/ai_voicechanger/ui_act(action, params)
Expand Down Expand Up @@ -1010,9 +1013,23 @@ GLOBAL_LIST_INIT(malf_modules, subtypesof(/datum/ai_module))
if(changing_voice)
owner.radio.use_command = loudvoice
if("look")
say_span = params["look"]
var/selection = params["look"]
if(isnull(selection))
return FALSE

var/found = FALSE
for(var/option in voice_options)
if(option == selection)
found = TRUE
break
if(!found)
stack_trace("User attempted to select an unavailable voice option")
return FALSE

say_span = selection
if(changing_voice)
owner.speech_span = say_span
to_chat(usr, span_notice("Voice set to [selection]."))
if("verb")
say_verb = params["verb"]
if(changing_voice)
Expand Down
206 changes: 116 additions & 90 deletions tgui/packages/tgui/components/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { classes } from 'common/react';
import { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { ReactNode, useEffect, useRef, useState } from 'react';

import { BoxProps, unit } from './Box';
import { Button } from './Button';
import { Icon } from './Icon';
import { Popper } from './Popper';

type DropdownEntry = {
export type DropdownEntry = {
displayText: ReactNode;
value: string | number;
};
Expand All @@ -19,7 +19,11 @@ type Props = {
options: DropdownOption[];
/** Called when a value is picked from the list, `value` is the value that was picked */
onSelected: (value: any) => void;
/** Currently selected entry to display. Can be left stateless to permanently display this value. */
selected: DropdownOption | null | undefined;
} & Partial<{
/** Whether to scroll automatically on open. Defaults to true */
autoScroll: boolean;
/** Whether to display previous / next buttons */
buttons: boolean;
/** Whether to clip the selected text */
Expand All @@ -28,7 +32,7 @@ type Props = {
color: string;
/** Disables the dropdown */
disabled: boolean;
/** Text to always display in place of the selected text */
/** Overwrites selection text with this. Good for objects etc. */
displayText: ReactNode;
/** Icon to display in dropdown button */
icon: string;
Expand All @@ -44,17 +48,26 @@ type Props = {
onClick: (event) => void;
/** Dropdown renders over instead of below */
over: boolean;
/** Currently selected entry */
selected: string | number;
/** Text to show when nothing has been selected. */
placeholder: string;
}> &
BoxProps;

enum DIRECTION {
Previous = 'previous',
Next = 'next',
Current = 'current',
}

const NONE = -1;

function getOptionValue(option: DropdownOption) {
return typeof option === 'string' ? option : option.value;
}

export function Dropdown(props: Props) {
const {
autoScroll = true,
buttons,
className,
clipSelectedText = true,
Expand All @@ -70,46 +83,64 @@ export function Dropdown(props: Props) {
onSelected,
options = [],
over,
placeholder = 'Select...',
selected,
width,
width = '15rem',
} = props;

const [open, setOpen] = useState(false);
const adjustedOpen = over ? !open : open;
const innerRef = useRef<HTMLDivElement>(null);

/** Update the selected value when clicking the left/right buttons */
const updateSelected = useCallback(
(direction: 'previous' | 'next') => {
if (options.length < 1 || disabled) {
return;
}
const startIndex = 0;
const endIndex = options.length - 1;
const selectedIndex =
options.findIndex((option) => getOptionValue(option) === selected) || 0;

let selectedIndex = options.findIndex(
(option) => getOptionValue(option) === selected,
);
function scrollTo(position: number) {
let scrollPos = position;
if (position < selectedIndex) {
scrollPos = position < 2 ? 0 : position - 2;
} else {
scrollPos =
position > options.length - 3 ? options.length - 1 : position - 2;
}

if (selectedIndex < 0) {
selectedIndex = direction === 'next' ? endIndex : startIndex;
}
const element = innerRef.current?.children[scrollPos];
element?.scrollIntoView({ block: 'nearest' });
}

let newIndex = selectedIndex;
if (direction === 'next') {
newIndex = selectedIndex === endIndex ? startIndex : ++selectedIndex;
} else {
newIndex = selectedIndex === startIndex ? endIndex : --selectedIndex;
}

onSelected?.(getOptionValue(options[newIndex]));
},
[disabled, onSelected, options, selected],
);
/** Update the selected value when clicking the left/right buttons */
function updateSelected(direction: DIRECTION) {
if (options.length < 1 || disabled) {
return;
}

const startIndex = 0;
const endIndex = options.length - 1;

let newIndex: number;
if (selectedIndex < 0) {
newIndex = direction === 'next' ? endIndex : startIndex; // No selection yet
} else if (direction === 'next') {
newIndex = selectedIndex === endIndex ? startIndex : selectedIndex + 1; // Move to next option
} else {
newIndex = selectedIndex === startIndex ? endIndex : selectedIndex - 1; // Move to previous option
}

if (open && autoScroll) {
scrollTo(newIndex);
}
onSelected?.(getOptionValue(options[newIndex]));
}

/** Allows the menu to be scrollable on open */
useEffect(() => {
if (!open) return;
if (!open) {
return;
}

if (autoScroll && selectedIndex !== NONE) {
scrollTo(selectedIndex);
}

innerRef.current?.focus();
}, [open]);
Expand Down Expand Up @@ -151,69 +182,64 @@ export function Dropdown(props: Props) {
</div>
}
>
<div>
<div className="Dropdown" style={{ width: unit(width) }}>
<div
className={classes([
'Dropdown__control',
'Button',
'Button--dropdown',
'Button--color--' + color,
disabled && 'Button--disabled',
className,
])}
onClick={(event) => {
if (disabled && !open) {
return;
}
setOpen(!open);
onClick?.(event);
<div className="Dropdown" style={{ width: unit(width) }}>
<div
className={classes([
'Dropdown__control',
'Button',
'Button--dropdown',
'Button--color--' + color,
disabled && 'Button--disabled',
className,
])}
onClick={(event) => {
if (disabled && !open) {
return;
}
setOpen(!open);
onClick?.(event);
}}
>
{icon && (
<Icon mr={1} name={icon} rotation={iconRotation} spin={iconSpin} />
)}
<span
className="Dropdown__selected-text"
style={{
overflow: clipSelectedText ? 'hidden' : 'visible',
}}
>
{icon && (
<Icon
mr={1}
name={icon}
rotation={iconRotation}
spin={iconSpin}
/>
)}
<span
className="Dropdown__selected-text"
style={{
overflow: clipSelectedText ? 'hidden' : 'visible',
}}
>
{displayText || selected}
{displayText ||
(selected && getOptionValue(selected)) ||
placeholder}
</span>
{!noChevron && (
<span className="Dropdown__arrow-button">
<Icon name={adjustedOpen ? 'chevron-up' : 'chevron-down'} />
</span>
{!noChevron && (
<span className="Dropdown__arrow-button">
<Icon name={adjustedOpen ? 'chevron-up' : 'chevron-down'} />
</span>
)}
</div>
{buttons && (
<>
<Button
disabled={disabled}
height={1.8}
icon="chevron-left"
onClick={() => {
updateSelected('previous');
}}
/>

<Button
disabled={disabled}
height={1.8}
icon="chevron-right"
onClick={() => {
updateSelected('next');
}}
/>
</>
)}
</div>
{buttons && (
<>
<Button
disabled={disabled}
height={1.8}
icon="chevron-left"
onClick={() => {
updateSelected(DIRECTION.Previous);
}}
/>

<Button
disabled={disabled}
height={1.8}
icon="chevron-right"
onClick={() => {
updateSelected(DIRECTION.Next);
}}
/>
</>
)}
</div>
</Popper>
);
Expand Down
5 changes: 2 additions & 3 deletions tgui/packages/tgui/interfaces/AdminFax.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,9 @@ export const FaxMainPanel = (props) => {
<Box fontSize="13px">
<Dropdown
textAlign="center"
selected="Choose fax machine..."
placeholder="Choose fax machine..."
width="100%"
noChevron
nowrap
selected={fax}
options={data.faxes}
onSelected={(value) => setFax(value)}
/>
Expand Down
3 changes: 2 additions & 1 deletion tgui/packages/tgui/interfaces/AdminPDA.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ const ReceiverChoice = (props) => {
<Dropdown
disabled={spam}
selected={user}
displayText={user ? users[user].username : 'Pick a user...'}
displayText={users[user]?.username}
placeholder="Pick a user..."
options={receivers
.filter((rcvr) => showInvisible || !rcvr.invisible)
.map((rcvr) => ({
Expand Down
Loading
Loading