React detect click outside component clearing my state - javascript

I have an outsideAlerter component that functions elsewhere on my site. I am now using it on a repeatable component and for some reason it is clearing my state effectively breaking my desired outcome.
below is my wrapper component that detects if you click outside of its children
import React, { useRef, useEffect } from "react";
/**
* Hook that alerts clicks outside of the passed ref
*/
function useOutsideAlerter(ref, onClickOutside) {
useEffect(() => {
/**
* Alert if clicked on outside of element
*/
function handleClickOutside(event) {
if (ref.current && !ref.current.contains(event.target)) {
//console.log(onClickOutside);
onClickOutside();
}
}
// Bind the event listener
document.addEventListener("mousedown", handleClickOutside);
return () => {
// Unbind the event listener on clean up
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
}
/**
* Component that alerts if you click outside of it
*/
export default function OutsideAlerter(props) {
const wrapperRef = useRef(null);
useOutsideAlerter(wrapperRef, props.onClickOutside);
return <div ref={wrapperRef}>{props.children}</div>;
}
Below is my controller component, it handles state
const TableFilterDropdownController = ({style, rows, colKey, activeFilters, addActiveFilter}) => {
const [tableFilterState, setTableFilterState] = useState(
{
state: INACTIVE,
iconColor: "black",
filter: "",
filteredRows: [...rows],
localActiveFilters: []
}
);
useEffect(() => {
let state = tableFilterState.state;
let localActiveFilters = tableFilterState.localActiveFilters;
if (state === INACTIVE && localActiveFilters.length > 0) {
setTableFilterState({...tableFilterState, state: ACTIVE})
}
}, [tableFilterState.state])
//filter out repeats and rows that don't match input
useEffect(() => {
let filter = tableFilterState.filter
if (filter !== "") {
let tempFilteredRows = [];
rows.map(row => {
if (row[colKey].toLowerCase().includes(filter.toLowerCase()) &&
!tempFilteredRows.includes(row[colKey])) {
tempFilteredRows.push(row[colKey]);
}
})
setTableFilterState({...tableFilterState, filteredRows: tempFilteredRows})
}
else {
let tempFilteredRows = [];
rows.map(row => {
if (!tempFilteredRows.includes(row[colKey])) {
tempFilteredRows.push(row[colKey]);
}
})
setTableFilterState({...tableFilterState, filteredRows: tempFilteredRows});
}
}, [tableFilterState.filter, rows])
const onClick = () => {
if (tableFilterState.state === DROP_DOWN) {
console.log(tableFilterState)
if (tableFilterState.localActiveFilters.length > 0) {
//setState(ACTIVE)
setTableFilterState({...tableFilterState, state: ACTIVE});
}
else {
//setState(INACTIVE)
setTableFilterState({...tableFilterState, state: INACTIVE});
}
}
else {
//setState(DROP_DOWN)
setTableFilterState({...tableFilterState, state: DROP_DOWN});
}
}
//something here is breaking it and resetting on click outside
const onClickOutside = () => {
setTableFilterState({...tableFilterState, state: INACTIVE});
}
let addLocalActiveFilter = (filter) => {
let newActiveFilters = [...tableFilterState.localActiveFilters];
const index = newActiveFilters.indexOf(filter);
if (index > -1) {
newActiveFilters.splice(index, 1);
} else {
newActiveFilters.push(filter);
}
setTableFilterState({...tableFilterState, localActiveFilters: newActiveFilters});
}
return (
<TableFilterDropdown
style={style}
color={tableFilterState.iconColor}
state={tableFilterState.state}
onClick={onClick}
onClickOutside={onClickOutside}
dropLeft={true}
filter={tableFilterState.filter}
setFilter={e => setTableFilterState({...tableFilterState, filter: e.target.value})}
>
{tableFilterState.filteredRows.map((item, index) => {
return (
<CheckboxInput
value={item}
label={item}
key={index}
onChange={e => {
addActiveFilter(e.target.value);
addLocalActiveFilter(e.target.value)
}}
isChecked={tableFilterState.localActiveFilters.includes(item)}
/>
);
})}
</TableFilterDropdown>
);
}
export default TableFilterDropdownController;
And lastly below is the UI component
const TableFilterDropdown = ({style, state, color, children, onClick, onClickOutside, dropLeft, filter, setFilter}) => {
useEffect(() => {
console.log("state change")
console.log(state);
}, [state])
return (
<div
className={`sm:relative inline-block ${style}`}
>
<OutsideAlerter onClickOutside={onClickOutside}>
<IconButton
type="button"
style={`relative text-2xl`}
onClick={onClick}
>
<IconContext.Provider value={{color: color}}>
<div>
{state === DROP_DOWN ?
<AiFillCloseCircle /> :
state === ACTIVE ?
<AiFillFilter /> :
<AiOutlineFilter />
}
</div>
</IconContext.Provider>
</IconButton>
{state === DROP_DOWN ?
<div className={`flex flex-col left-0 w-screen sm:w-32 max-h-40 overflow-auto ${dropLeft ? "sm:origin-top-left sm:left-[-2.5rem]" : "sm:origin-top-right sm:right-0"} absolute mt-2 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-10`} role="menu" aria-orientation="vertical" aria-labelledby="menu-button">
<SearchBar label={"Search"} placeholder={"Search"} value={filter} onChange={setFilter} />
{children}
</div>
: null}
</OutsideAlerter>
</div>
);
For some reason whenever you click outside the component the tableFilterState gets set to
{
state: INACTIVE,
iconColor: "black",
filter: "",
filteredRows: [],
localActiveFilters: []
}
Which is not intentional, the tableFilterState should stay the same, only state should change. I can't figure this out so please help!!!

When you call useOutsideAlerter and pass onClickOutside handler it captures tableFilterState value and use it in a subsequent calls. This is a stale state. You could try this approach or use refs as described in docs:
const onClickOutside = () => {
setTableFilterState(tableFilterState => ({
...tableFilterState,
state: INACTIVE,
}));
}

Related

Cannot interact with MUI stepper and change a value

I want to use an MUI stepper to replace a Select component. The select component is used to indicate the status of the document the user is working in (New, In Progress, Complete, etc.). I have managed to display the correct status in the stepper, but I cannot interact with it to move the status forward or back.
This is my stepper file. I am passing the status value through props:
export default function IntakeStatusBar(props) {
const { status } = props;
const classes = useStyles();
const [activeStep, setActiveStep] = useState(0);
const steps = ["New", "In Progress", "Completed"];
useEffect(() => {
if (status === "In Progress") {
setActiveStep(1);
} else if (status === "Completed") {
setActiveStep(2);
} else setActiveStep(0);
}, [status, activeStep]);
const handleStep = (step) => () => {
setActiveStep(step);
};
return (
<div className={classes.root}>
<Stepper activeStep={activeStep} alternativeLabel>
{steps.map((label, index) => (
<Step key={label}>
<StepButton onClick={handleStep(index)}>{label}</StepButton>
</Step>
))}
</Stepper>
</div>
);
}
This is where I call and display the stepper:
export default function IntakeDetails() {
const [details, setDetails] = useState("");
const onTextChange = (e) => {
var id = e.target.id ? e?.target.id : e?.target.name;
var value = e.target.value;
setDetails({ ...details, [id]: value });
}
....
return (
<IntakeStatusBar status={details?.Status} onChange={onTextChange} />
// This is the Select drop down menu I have been using
<TextField
label="Status"
error={requiredField && details?.Status?.length <= 0}
value={details?.Status}
disabled={!(adminRole && isSolutionsTab && details?.Status !== "In Plan")}
select
onChange={onTextChange}
>
{details.StatusList?.map((choice) => {
return (
<MenuItem key={choice} value={choice}>
{choice}
</MenuItem>
);
})}
</TextField>
)
}
This is what the status field looks like in JSON:
{
Status: "New"
}
besides changing this:
<StepButton onClick={() => handleStep(index)}>{label}</StepButton>
you have to change this:
const handleStep = (step) => {
setActiveStep(step);
};
and set Stepper to nonLinear if you want user to click on steps:
<Stepper nonLinear activeStep={activeStep} alternativeLabel>
I also commented out useEffect since I had no idea what its purpose is and it's messing with activeStep state.

React TypeScript MultiSelector using checkbox

i am trying to create a search form using React typescript props event.I have acheived half of it but now stuck on an checkbox multiSelector where i have no idea how we can implement it.i have googled a lot but got nothing in return.
here is my code.
I am using common typescript props event onChange for setting all the values inside my search Api Object.
can anyone help me out with code or docs how we can acheive multiSelector checkbox for React Typescript props event.
1.here is my search for structure=>
enter code here
let columns = useMemo(
() => [
{
Header: "Name", accessor: "username",
Cell: (props: any) => {
if (authoritiesList.includes("USR-U")) {
let path = "/users/" + props.value;
return createClickableLink(path, props.value);
} else {
return <>
<span>{props.row.original.username}</span>
</>
}
},
FilterOptions: {
FilterInput:
<input type="text" placeholder="Username..." />,
overrideFilterLabel: "Username",
overrideFilterAccessor: "username"
}
},
{
Header: "Role(s)", accessor: "roles", disableSortBy: true,
Cell: (props: any) => {
return <>
{props.row.original.roles.map((role: any) => {
return (
<div>
<span>{role}</span><br/>
</div>)
})}
</>
},
FilterOptions: {
FilterSelect:
roleData.items.map((curRole:any)=>{
return (
<input type="checkbox value=
{curRole.name} />
)
})} ,
overrideFilterLabel: "Roles",
overrideFilterAccessor: "roles"
}
},
},
], [customerData,roleData]
)
enter code here
const selector = (state: any) => state.users;
return (
<div className="m-0 p-0 ">
<section id="content-wrapper">
<div className="row">
<div className="col-lg-12 ml-auto">
<Breadcrumb crumbs={crumbs}/>
<div className="table_data mt-2">
{createSearchButton()}
{authoritiesList.includes("USR-C") && createAddButton("/users/create", "Add User")}
<DataTable columns={columns}
fetchAction={userActions.getAllData as Dispatch<Action>}
exportAction={userActions.exportData as Dispatch<Action>}
selector={selector}/>
</div>
</div>
</div>
</section>
</div>
);
}
I want to handle multi selected checkbox event for this form in
Typescript. all forms input tags are working currently but multiselected checkbox is not working for brining output to the query object.
here is my typescript code.
for (let column of tableColumns) {
if (!column.FilterOptions) {
column.FilterOptions = {};
}
if (column.FilterOptions?.FilterSelect) {
column.FilterOptions.FilterSelect.props.onKeyPress = (event: KeyboardEvent) => {
event.key === 'Enter' && setApplyFilter(true);
}
column.FilterOptions.FilterSelect.props.onChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
updateFilterQuerySelect(column, filterQuery, setFilterQuery, event);
}
}
if (column.FilterOptions?.FilterInput) {
column.FilterOptions.FilterInput.props.onKeyPress = (event: KeyboardEvent) => {
event.key === 'Enter' && setApplyFilter(true);
}
column.FilterOptions.FilterInput.props.onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
updateFilterQuery(column, filterQuery, setFilterQuery, event);
}
}
}
here is function updateFilterQuery
const updateFilterQuery = (column: DataTableColumn, filterQuery: any, setFilterQuery: Function, event: React.ChangeEvent) => {
let tempQuery: any = {...filterQuery};
let key: string = column.FilterOptions?.overrideFilterAccessor || column.accessor;
let value: any = event.target.value;
if (event.target.value == "on" && event.target.checked != undefined) {
value = event.target.checked;
}
if (event.target.value == undefined) {
delete tempQuery[key];
} else {
key === 'phone' ? tempQuery[key] = getUnformattedPhoneNumber(value)
:
tempQuery[key] = value;
}
setFilterQuery(tempQuery);
}
It is a search form and similary it is working same as for other forms as well th eonly part missing in this form is now multiselector which is not working.
You have to separate selection state into a custom hook. A state is an array of selected items.
CodeSandbox
hooks.ts
import React, { useState } from "react";
export const useMultiselect = (initialValue: string[]) => {
const [selected, setSelected] = useState<string[]>(initialValue);
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
const index = selected.indexOf(value);
if (index > -1) {
setSelected([...selected.slice(0, index), ...selected.slice(index + 1)]);
} else {
setSelected([...selected, ...[value]]);
}
};
const isSelected = (value: string) => {
return selected.includes(value);
};
return { selected, isSelected, onChange };
};
App.tsx
import { useMultiselect } from "./hooks";
const data = ["Apple", "Orange", "Banana", "Pear", "Peach"];
export default function App() {
const { selected, isSelected, onChange } = useMultiselect([]);
return (
<div>
<div>Select your favorite fruites!</div>
<ul style={{ listStyleType: "none" }}>
{data.map((value) => (
<li key={value}>
<input
id={value}
type="checkbox"
value={value}
checked={isSelected(value)}
onChange={onChange}
/>
<label htmlFor={value}>{value}</label>
</li>
))}
</ul>
<div>Selected: {selected.join()}</div>
</div>
);
}

How to escape infinite loop of useState hook in React?

I am new to react and learning about hooks but I cannot escape from useState at second execution.
My caller function is calling another component called QueryPanel with parameter:
const [availables, setAvailable] = useState<string[]>([]);
useEffect(() => {
context.services.records.getAvailablesList()
.then((resp) => {
if(resp != undefined) {
setAvailables(resp);
}
});
}, []);
return <Panel1 text={availables}></Panel1>;
So Panel1 is opening with a string array or an empty array. What I want to do is that show dropdown and if the parameter is not empty then show first item as default item or if the parameter list is empty then show a string as a default value "No option available"
export const QueryPanel = (props: any) => {
const t = useTrans();
const context = useContext(AppContext);
const [form] = Form.useForm<Store>();
const [defaultValue, setDefaultValue] = useState("asd");
const { Option } = Select;
const onFinish = (values: Store) => {
const queryParams: Query = {
system: values.systemType,
startDate: values.startDate,
endDate: values.endDate,
startFrequency: values.startFrequency,
endFrequency: values.endFrequency
};
context.services.records.query(queryParams);
};
const validateOnFormChange = () => {
form.validateFields();
};
const onFinishFailed = (errorInfo: ValidateErrorEntity) => {
console.log('Failed:', errorInfo);
};
// useEffect(() => {
//
// if (props.text.length !== 0) {
// setDefaultValue(props.text[0]);
// }
// else {
// setDefaultValue("No option available");
// }
// }, []);
// if (props.text.length > 0) {
// setDefaultDbValue(props.text[0]);
// }
// else {
// let defaultDropDownOption:string = "No Db Available";
// setDefaultDbValue(defaultDropDownOption);
// }
My if-else is stuck in infinite loop because whenever I set default value to a value then it goes to same if statement again. How can I escape from it without using extra field ? useEffect is commented out here and the weird thing is when i check the parameter prop.text it is undefined at beginning and thats why the code is not going inside if-else in useEffect and then at second iteration the prop.text is coming as true.
I have tried something with useEffect hook but did not work:
useEffect(() => {
if (props.text.length > 0) {
setDefaultValue(props.text[0]);
}
}, [defaultValue]);
But this time default db is coming empty
Here is the complete code of Panel1 (QueryPanel) but the some names are different
import { Button, Col, DatePicker, Form, InputNumber, Row, Select } from 'antd';
import { Store } from 'antd/lib/form/interface';
import { ValidateErrorEntity } from 'rc-field-form/lib/interface';
import React, {useContext, useEffect, useState} from 'react';
import { AppContext } from '../../../../shared/Context';
import './QueryPanel.less';
import { Query } from '../../../../rest/BackendApi';
import { SystemType } from '../../../../shared/models/SystemType';
import { useTrans } from "../../../../shared/i18n/i18n";
export const QueryPanel = (props: any) => {
const t = useTrans();
const context = useContext(AppContext);
const [form] = Form.useForm<Store>();
const [defaultDbValue, setDefaultDbValue] = useState("Select Db");
const { Option } = Select;
const onFinish = (values: Store) => {
const queryParams: Query = {
system: values.systemType,
startDate: values.startDate,
endDate: values.endDate,
startFrequency: values.startFrequency,
endFrequency: values.endFrequency
};
context.services.records.query(queryParams);
};
const validateOnFormChange = () => {
form.validateFields();
};
const onFinishFailed = (errorInfo: ValidateErrorEntity) => {
console.log('Failed:', errorInfo);
};
// useEffect(() => {
// if (props.text.length !== 0) {
// setDefaultDbValue(props.text[0]);
// }
// else {
// setDefaultDbValue("No db available");
// }
// }, []);
useEffect(() => {
if (!props.text) return;
if (props.text.length > 0) {
setDefaultDbValue(props.text[0]);
}
else setDefaultDbValue("No Db Available");
}, [props.text?.length]);
return (
<div>
<Form form={form} onFinish={onFinish} onFinishFailed={onFinishFailed} onValuesChange={validateOnFormChange}>
<Row justify={"space-between"} >
<Col span={4}>
<Form.Item label={t.trans.queryPanel.systemType} name='systemType' className={"inline-form-item"}>
<Select defaultValue={defaultDbValue}>
{props.text.map((element: any) => (
<Option key={element} value={element}>
{element}
</Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={4}>
<Form.Item label={t.trans.queryPanel.startDate} name='startDate' className={"inline-form-item"}
rules={[
({ getFieldValue }) => ({
validator(rule, value) {
if (!value || !getFieldValue('endDate') || getFieldValue('endDate') >= value) {
return Promise.resolve();
}
return Promise.reject(t.trans.queryPanel.errors.startDate);
},
}),
]}>
<DatePicker format="DD-MM-YYYY" />
</Form.Item>
</Col>
<Col span={4}>
<Form.Item label={t.trans.queryPanel.endDate} name='endDate' className={"inline-form-item"}
rules={[
({ getFieldValue }) => ({
validator(rule, value) {
if (!value || !getFieldValue('startDate') || getFieldValue('startDate') <= value) {
return Promise.resolve();
}
return Promise.reject(t.trans.queryPanel.errors.endDate);
},
}),
]}>
<DatePicker format="DD-MM-YYYY" />
</Form.Item>
</Col>
<Col span={4}>
<Form.Item label={t.trans.queryPanel.startFrequency} name='startFrequency' className={"inline-form-item"}
rules={[
({ getFieldValue }) => ({
validator(rule, value) {
if (!value) return Promise.resolve();
if (value < 0 || value > 200) {
return Promise.reject(t.trans.queryPanel.errors.startFrequency);
} else if (getFieldValue('endFrequency') && getFieldValue('endFrequency') < value) {
return Promise.reject(t.trans.queryPanel.errors.startFrequencyValues);
}
return Promise.resolve();
},
}),
]}>
<InputNumber />
</Form.Item>
</Col>
<Col span={4}>
<Form.Item label={t.trans.queryPanel.endFrequency} name='endFrequency' className={"inline-form-item"}
rules={[
({ getFieldValue }) => ({
validator(rule, value) {
if (!value) return Promise.resolve();
if (value < 0 || value > 400) {
return Promise.reject(t.trans.queryPanel.errors.endFrequency);
} else if (getFieldValue('startFrequency') && getFieldValue('startFrequency') > value) {
return Promise.reject(t.trans.queryPanel.errors.endFrequencyValues);
}
return Promise.resolve();
},
}),
]}>
<InputNumber />
</Form.Item>
</Col>
<Col span={2}>
<Form.Item className={"inline-form-item"}>
<Button type="primary" htmlType="submit">
{t.trans.queryPanel.search}
</Button>
</Form.Item>
</Col>
</Row>
</Form>
</div>
);
}
The infinite loop is created because React will rerender your <Panel1 /> component whenever the state of the component changes.
In the body of your component you have this if ... else statement sitting without wrapping it in a hook:
if (props.text.length > 0) {
setDefaultDbValue(props.text[0]);
} else {
let defaultDropDownOption:string = "No Db Available";
setDefaultDbValue(defaultDropDownOption);
}
So, either way you will execute setDefaultDbValue() which will update the state and trigger a rerender.
To prevent this loop you can wrap this snipped with useEffect() and use your text.length as a dependency:
useEffect(() => {
if (!props.text) return; // wait until it is defined.
if (props.text.length > 0) setDefaultDbValue(props.text[0]);
else setDefaultDbValue("No Db Available");
}, [props.text?.length]);
There is no need to pass defaultValue as dependency to the useEffect hook. Or do I missunderstand your intention with this?
This aside: the better way to set default values is to do it in the useState(<DEFAULT_VALUE>) hook directly. Just the way you did it. Why is this not an option for you?
Edit
After your edit it is clear that the issue lies in how Ant uses the prop defaultValue. This prop is not reactive - so updating it after the Select has been rendered has no effect.
So you can either wait for the request to resolve before rendering your Panel, or:
If you want to select the first element of your text prop, once it is loaded, you have to set the value of the Select accordingly. Note that you have to update the value via onChange handler, if you bind the value to a state variable.
Here an example:
const Select = antd.Select;
const values = [
'one',
'two',
'three',
];
// This is just a dummy representing your `text` prop
const lazyValues = new Promise((res) => {
setTimeout(() => {
res(values);
}, 2000);
});
const App = () => {
const [text, setText] = React.useState([]);
const [value, setValue] = React.useState();
React.useEffect(() => {
// fake the request
lazyValues.then((values) => setText(values));
}, []);
// if `text` changes its value, set the Select to its first element
React.useEffect(() => {
if (text.length > 0 && !value) setValue(text[0]);
}, [text]);
return <div>
<Select onChange={setValue /* Need to update our value manually */} defaultValue={"Default value visible if there is no value"} value={value}>
{text.map((text, i) => <Select.Option key={i} value={text}>{text}</Select.Option>)}
</Select>
</div>
}
ReactDOM.render(<App />, document.getElementById('app'));
<script crossorigin src="https://unpkg.com/react#17/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom#17/umd/react-dom.development.js"></script>
<script crossorigin src="https://unpkg.com/antd#4.17.4/dist/antd.min.js"></script>
<link rel='stylesheet' type="text/css" href="https://unpkg.com/antd#4.17.4/dist/antd.min.css"></link>
<div id="app"/>
I believe that one way would be to do :
useEffect(() => {
if (props.text.length > 0 && props.text[0] !== defaultValue) {
setDefaultValue(props.text[0]);
}
}, [defaultValue]);
By doing so, you will update the defaultValue only when it differs from props.text[0]

How to keyboard navigate ListGroup and ListGroupItem [duplicate]

I have build a simple component with a single text input and below of that a list (using semantic ui).
Now I would like to use the arrow keys to navigate through the list.
First of all I have to select the first element. But how do I access a specific list element?
Second I would get the information of the current selected element and select the next element. How do I get the info which element is selected?
Selection would mean to add the class active to the item or is there a better idea for that?
export default class Example extends Component {
constructor(props) {
super(props)
this.handleChange = this.handleChange.bind(this)
this.state = { result: [] }
}
handleChange(event) {
// arrow up/down button should select next/previous list element
}
render() {
return (
<Container>
<Input onChange={ this.handleChange }/>
<List>
{
result.map(i => {
return (
<List.Item key={ i._id } >
<span>{ i.title }</span>
</List.Item>
)
})
}
</List>
</Container>
)
}
}
Try something like this:
export default class Example extends Component {
constructor(props) {
super(props)
this.handleKeyDown = this.handleKeyDown.bind(this)
this.state = {
cursor: 0,
result: []
}
}
handleKeyDown(e) {
const { cursor, result } = this.state
// arrow up/down button should select next/previous list element
if (e.keyCode === 38 && cursor > 0) {
this.setState( prevState => ({
cursor: prevState.cursor - 1
}))
} else if (e.keyCode === 40 && cursor < result.length - 1) {
this.setState( prevState => ({
cursor: prevState.cursor + 1
}))
}
}
render() {
const { cursor } = this.state
return (
<Container>
<Input onKeyDown={ this.handleKeyDown }/>
<List>
{
result.map((item, i) => (
<List.Item
key={ item._id }
className={cursor === i ? 'active' : null}
>
<span>{ item.title }</span>
</List.Item>
))
}
</List>
</Container>
)
}
}
The cursor keeps track of your position in the list, so when the user presses the up or down arrow key you decrement/increment the cursor accordingly. The cursor should coincide with the array indices.
You probably want onKeyDown for watching the arrow keys instead of onChange, so you don't have a delay or mess with your standard input editing behavior.
In your render loop you just check the index against the cursor to see which one is active.
If you are filtering the result set based on the input from the field, you can just reset your cursor to zero anytime you filter the set so you can always keep the behavior consistent.
The accepted answer was very useful to me thanks! I adapted that solution and made a react hooks flavoured version, maybe it will be useful to someone:
import React, { useState, useEffect } from "react";
import ReactDOM from "react-dom";
import "./styles.css";
const useKeyPress = function(targetKey) {
const [keyPressed, setKeyPressed] = useState(false);
React.useEffect(() => {
const downHandler = ({ key }) => {
if (key === targetKey) {
setKeyPressed(true);
}
}
const upHandler = ({ key }) => {
if (key === targetKey) {
setKeyPressed(false);
}
};
window.addEventListener("keydown", downHandler);
window.addEventListener("keyup", upHandler);
return () => {
window.removeEventListener("keydown", downHandler);
window.removeEventListener("keyup", upHandler);
};
}, [targetKey]);
return keyPressed;
};
const items = [
{ id: 1, name: "Josh Weir" },
{ id: 2, name: "Sarah Weir" },
{ id: 3, name: "Alicia Weir" },
{ id: 4, name: "Doo Weir" },
{ id: 5, name: "Grooft Weir" }
];
const ListItem = ({ item, active, setSelected, setHovered }) => (
<div
className={`item ${active ? "active" : ""}`}
onClick={() => setSelected(item)}
onMouseEnter={() => setHovered(item)}
onMouseLeave={() => setHovered(undefined)}
>
{item.name}
</div>
);
const ListExample = () => {
const [selected, setSelected] = useState(undefined);
const downPress = useKeyPress("ArrowDown");
const upPress = useKeyPress("ArrowUp");
const enterPress = useKeyPress("Enter");
const [cursor, setCursor] = useState(0);
const [hovered, setHovered] = useState(undefined);
useEffect(() => {
if (items.length && downPress) {
setCursor(prevState =>
prevState < items.length - 1 ? prevState + 1 : prevState
);
}
}, [downPress]);
useEffect(() => {
if (items.length && upPress) {
setCursor(prevState => (prevState > 0 ? prevState - 1 : prevState));
}
}, [upPress]);
useEffect(() => {
if (items.length && enterPress) {
setSelected(items[cursor]);
}
}, [cursor, enterPress]);
useEffect(() => {
if (items.length && hovered) {
setCursor(items.indexOf(hovered));
}
}, [hovered]);
return (
<div>
<p>
<small>
Use up down keys and hit enter to select, or use the mouse
</small>
</p>
<span>Selected: {selected ? selected.name : "none"}</span>
{items.map((item, i) => (
<ListItem
key={item.id}
active={i === cursor}
item={item}
setSelected={setSelected}
setHovered={setHovered}
/>
))}
</div>
);
};
const rootElement = document.getElementById("root");
ReactDOM.render(<ListExample />, rootElement);
Attributing useKeyPress functionality to this post.
Pretty much same solution as what #joshweir provided, but in Typescript. Also instead of 'window' object I used 'ref' and added the event listeners only to the input text box.
import React, { useState, useEffect, Dispatch, SetStateAction, createRef, RefObject } from "react";
const useKeyPress = function (targetKey: string, ref: RefObject<HTMLInputElement>) {
const [keyPressed, setKeyPressed] = useState(false);
const downHandler = ({ key }: { key: string }) => {
if (key === targetKey) {
setKeyPressed(true);
}
}
const upHandler = ({ key }: { key: string }) => {
if (key === targetKey) {
setKeyPressed(false);
}
};
React.useEffect(() => {
ref.current?.addEventListener("keydown", downHandler);
ref.current?.addEventListener("keyup", upHandler);
return () => {
ref.current?.removeEventListener("keydown", downHandler);
ref.current?.removeEventListener("keyup", upHandler);
};
});
return keyPressed;
};
const items = [
{ id: 1, name: "Josh Weir" },
{ id: 2, name: "Sarah Weir" },
{ id: 3, name: "Alicia Weir" },
{ id: 4, name: "Doo Weir" },
{ id: 5, name: "Grooft Weir" }
];
const i = items[0]
type itemType = { id: number, name: string }
type ListItemType = {
item: itemType
, active: boolean
, setSelected: Dispatch<SetStateAction<SetStateAction<itemType | undefined>>>
, setHovered: Dispatch<SetStateAction<itemType | undefined>>
}
const ListItem = ({ item, active, setSelected, setHovered }: ListItemType) => (
<div
className={`item ${active ? "active" : ""}`}
onClick={() => setSelected(item)}
onMouseEnter={() => setHovered(item)}
onMouseLeave={() => setHovered(undefined)}
>
{item.name}
</div>
);
const ListExample = () => {
const searchBox = createRef<HTMLInputElement>()
const [selected, setSelected] = useState<React.SetStateAction<itemType | undefined>>(undefined);
const downPress = useKeyPress("ArrowDown", searchBox);
const upPress = useKeyPress("ArrowUp", searchBox);
const enterPress = useKeyPress("Enter", searchBox);
const [cursor, setCursor] = useState<number>(0);
const [hovered, setHovered] = useState<itemType | undefined>(undefined);
const [searchItem, setSearchItem] = useState<string>("")
const handelChange = (e: React.SyntheticEvent<HTMLInputElement>) => {
setSelected(undefined)
setSearchItem(e.currentTarget.value)
}
useEffect(() => {
if (items.length && downPress) {
setCursor(prevState =>
prevState < items.length - 1 ? prevState + 1 : prevState
);
}
}, [downPress]);
useEffect(() => {
if (items.length && upPress) {
setCursor(prevState => (prevState > 0 ? prevState - 1 : prevState));
}
}, [upPress]);
useEffect(() => {
if (items.length && enterPress || items.length && hovered) {
setSelected(items[cursor]);
}
}, [cursor, enterPress]);
useEffect(() => {
if (items.length && hovered) {
setCursor(items.indexOf(hovered));
}
}, [hovered]);
return (
<div>
<p>
<small>
Use up down keys and hit enter to select, or use the mouse
</small>
</p>
<div>
<input ref={searchBox} type="text" onChange={handelChange} value={selected ? selected.name : searchItem} />
{items.map((item, i) => (
<ListItem
key={item.id}
active={i === cursor}
item={item}
setSelected={setSelected}
setHovered={setHovered}
/>
))}
</div>
</div>
);
};
const rootElement = document.getElementById("root");
ReactDOM.render(<ListExample />, rootElement);
This is my attempt, with the downside that it requires the rendered children to pass ref correctly:
import React, { useRef, useState, cloneElement, Children, isValidElement } from "react";
export const ArrowKeyListManager: React.FC = ({ children }) => {
const [cursor, setCursor] = useState(0)
const items = useRef<HTMLElement[]>([])
const onKeyDown = (e) => {
let newCursor = 0
if (e.key === 'ArrowDown') {
newCursor = Math.min(cursor + 1, items.current.length - 1)
} else if (e.key === 'ArrowUp') {
newCursor = Math.max(0, cursor - 1)
}
setCursor(newCursor)
const node = items.current[newCursor]
node?.focus()
}
return (
<div onKeyDown={onKeyDown} {...props}>
{Children.map(children, (child, index) => {
if (isValidElement(child)) {
return cloneElement(child, {
ref: (n: HTMLElement) => {
items.current[index] = n
},
})
}
})}
</div>
)
}
Usage:
function App() {
return (
<ArrowKeyListManager>
<button onClick={() => alert('first')}>First</button>
<button onClick={() => alert('second')}>Second</button>
<button onClick={() => alert('third')}>third</button>
</ArrowKeyListManager>
);
}
It's a list with children that can be navigated by pressing the left-right & up-down key bindings.
Recipe.
Create an Array of Objects that will be used as a list using a map function on the data.
Create a useEffect and add an Eventlistener to listen for keydown actions in the window.
Create handleKeyDown function in order to configure the navigation behaviour by tracking the key that was pressed, use their keycodes fo that.
keyup: e.keyCode === 38
keydown: e.keyCode === 40
keyright: e.keyCode === 39
keyleft: e.keyCode === 37
Add State
let [activeMainMenu, setActiveMainMenu] = useState(-1);
let [activeSubMenu, setActiveSubMenu] = useState(-1);
Render by Mapping through the Array of objects
<ul ref={WrapperRef}>
{navigationItems.map((navigationItem, Mainindex) => {
return (
<li key={Mainindex}>
{activeMainMenu === Mainindex
? "active"
: navigationItem.navigationCategory}
<ul>
{navigationItem.navigationSubCategories &&
navigationItem.navigationSubCategories.map(
(navigationSubcategory, index) => {
return (
<li key={index}>
{activeSubMenu === index
? "active"
: navigationSubcategory.subCategory}
</li>
);
}
)}
</ul>
</li>
);
})}
</ul>
Find the above solution in the following link:
https://codesandbox.io/s/nested-list-accessible-with-keys-9pm3i1?file=/src/App.js:2811-3796

React: How to navigate through list by arrow keys

I have build a simple component with a single text input and below of that a list (using semantic ui).
Now I would like to use the arrow keys to navigate through the list.
First of all I have to select the first element. But how do I access a specific list element?
Second I would get the information of the current selected element and select the next element. How do I get the info which element is selected?
Selection would mean to add the class active to the item or is there a better idea for that?
export default class Example extends Component {
constructor(props) {
super(props)
this.handleChange = this.handleChange.bind(this)
this.state = { result: [] }
}
handleChange(event) {
// arrow up/down button should select next/previous list element
}
render() {
return (
<Container>
<Input onChange={ this.handleChange }/>
<List>
{
result.map(i => {
return (
<List.Item key={ i._id } >
<span>{ i.title }</span>
</List.Item>
)
})
}
</List>
</Container>
)
}
}
Try something like this:
export default class Example extends Component {
constructor(props) {
super(props)
this.handleKeyDown = this.handleKeyDown.bind(this)
this.state = {
cursor: 0,
result: []
}
}
handleKeyDown(e) {
const { cursor, result } = this.state
// arrow up/down button should select next/previous list element
if (e.keyCode === 38 && cursor > 0) {
this.setState( prevState => ({
cursor: prevState.cursor - 1
}))
} else if (e.keyCode === 40 && cursor < result.length - 1) {
this.setState( prevState => ({
cursor: prevState.cursor + 1
}))
}
}
render() {
const { cursor } = this.state
return (
<Container>
<Input onKeyDown={ this.handleKeyDown }/>
<List>
{
result.map((item, i) => (
<List.Item
key={ item._id }
className={cursor === i ? 'active' : null}
>
<span>{ item.title }</span>
</List.Item>
))
}
</List>
</Container>
)
}
}
The cursor keeps track of your position in the list, so when the user presses the up or down arrow key you decrement/increment the cursor accordingly. The cursor should coincide with the array indices.
You probably want onKeyDown for watching the arrow keys instead of onChange, so you don't have a delay or mess with your standard input editing behavior.
In your render loop you just check the index against the cursor to see which one is active.
If you are filtering the result set based on the input from the field, you can just reset your cursor to zero anytime you filter the set so you can always keep the behavior consistent.
The accepted answer was very useful to me thanks! I adapted that solution and made a react hooks flavoured version, maybe it will be useful to someone:
import React, { useState, useEffect } from "react";
import ReactDOM from "react-dom";
import "./styles.css";
const useKeyPress = function(targetKey) {
const [keyPressed, setKeyPressed] = useState(false);
React.useEffect(() => {
const downHandler = ({ key }) => {
if (key === targetKey) {
setKeyPressed(true);
}
}
const upHandler = ({ key }) => {
if (key === targetKey) {
setKeyPressed(false);
}
};
window.addEventListener("keydown", downHandler);
window.addEventListener("keyup", upHandler);
return () => {
window.removeEventListener("keydown", downHandler);
window.removeEventListener("keyup", upHandler);
};
}, [targetKey]);
return keyPressed;
};
const items = [
{ id: 1, name: "Josh Weir" },
{ id: 2, name: "Sarah Weir" },
{ id: 3, name: "Alicia Weir" },
{ id: 4, name: "Doo Weir" },
{ id: 5, name: "Grooft Weir" }
];
const ListItem = ({ item, active, setSelected, setHovered }) => (
<div
className={`item ${active ? "active" : ""}`}
onClick={() => setSelected(item)}
onMouseEnter={() => setHovered(item)}
onMouseLeave={() => setHovered(undefined)}
>
{item.name}
</div>
);
const ListExample = () => {
const [selected, setSelected] = useState(undefined);
const downPress = useKeyPress("ArrowDown");
const upPress = useKeyPress("ArrowUp");
const enterPress = useKeyPress("Enter");
const [cursor, setCursor] = useState(0);
const [hovered, setHovered] = useState(undefined);
useEffect(() => {
if (items.length && downPress) {
setCursor(prevState =>
prevState < items.length - 1 ? prevState + 1 : prevState
);
}
}, [downPress]);
useEffect(() => {
if (items.length && upPress) {
setCursor(prevState => (prevState > 0 ? prevState - 1 : prevState));
}
}, [upPress]);
useEffect(() => {
if (items.length && enterPress) {
setSelected(items[cursor]);
}
}, [cursor, enterPress]);
useEffect(() => {
if (items.length && hovered) {
setCursor(items.indexOf(hovered));
}
}, [hovered]);
return (
<div>
<p>
<small>
Use up down keys and hit enter to select, or use the mouse
</small>
</p>
<span>Selected: {selected ? selected.name : "none"}</span>
{items.map((item, i) => (
<ListItem
key={item.id}
active={i === cursor}
item={item}
setSelected={setSelected}
setHovered={setHovered}
/>
))}
</div>
);
};
const rootElement = document.getElementById("root");
ReactDOM.render(<ListExample />, rootElement);
Attributing useKeyPress functionality to this post.
Pretty much same solution as what #joshweir provided, but in Typescript. Also instead of 'window' object I used 'ref' and added the event listeners only to the input text box.
import React, { useState, useEffect, Dispatch, SetStateAction, createRef, RefObject } from "react";
const useKeyPress = function (targetKey: string, ref: RefObject<HTMLInputElement>) {
const [keyPressed, setKeyPressed] = useState(false);
const downHandler = ({ key }: { key: string }) => {
if (key === targetKey) {
setKeyPressed(true);
}
}
const upHandler = ({ key }: { key: string }) => {
if (key === targetKey) {
setKeyPressed(false);
}
};
React.useEffect(() => {
ref.current?.addEventListener("keydown", downHandler);
ref.current?.addEventListener("keyup", upHandler);
return () => {
ref.current?.removeEventListener("keydown", downHandler);
ref.current?.removeEventListener("keyup", upHandler);
};
});
return keyPressed;
};
const items = [
{ id: 1, name: "Josh Weir" },
{ id: 2, name: "Sarah Weir" },
{ id: 3, name: "Alicia Weir" },
{ id: 4, name: "Doo Weir" },
{ id: 5, name: "Grooft Weir" }
];
const i = items[0]
type itemType = { id: number, name: string }
type ListItemType = {
item: itemType
, active: boolean
, setSelected: Dispatch<SetStateAction<SetStateAction<itemType | undefined>>>
, setHovered: Dispatch<SetStateAction<itemType | undefined>>
}
const ListItem = ({ item, active, setSelected, setHovered }: ListItemType) => (
<div
className={`item ${active ? "active" : ""}`}
onClick={() => setSelected(item)}
onMouseEnter={() => setHovered(item)}
onMouseLeave={() => setHovered(undefined)}
>
{item.name}
</div>
);
const ListExample = () => {
const searchBox = createRef<HTMLInputElement>()
const [selected, setSelected] = useState<React.SetStateAction<itemType | undefined>>(undefined);
const downPress = useKeyPress("ArrowDown", searchBox);
const upPress = useKeyPress("ArrowUp", searchBox);
const enterPress = useKeyPress("Enter", searchBox);
const [cursor, setCursor] = useState<number>(0);
const [hovered, setHovered] = useState<itemType | undefined>(undefined);
const [searchItem, setSearchItem] = useState<string>("")
const handelChange = (e: React.SyntheticEvent<HTMLInputElement>) => {
setSelected(undefined)
setSearchItem(e.currentTarget.value)
}
useEffect(() => {
if (items.length && downPress) {
setCursor(prevState =>
prevState < items.length - 1 ? prevState + 1 : prevState
);
}
}, [downPress]);
useEffect(() => {
if (items.length && upPress) {
setCursor(prevState => (prevState > 0 ? prevState - 1 : prevState));
}
}, [upPress]);
useEffect(() => {
if (items.length && enterPress || items.length && hovered) {
setSelected(items[cursor]);
}
}, [cursor, enterPress]);
useEffect(() => {
if (items.length && hovered) {
setCursor(items.indexOf(hovered));
}
}, [hovered]);
return (
<div>
<p>
<small>
Use up down keys and hit enter to select, or use the mouse
</small>
</p>
<div>
<input ref={searchBox} type="text" onChange={handelChange} value={selected ? selected.name : searchItem} />
{items.map((item, i) => (
<ListItem
key={item.id}
active={i === cursor}
item={item}
setSelected={setSelected}
setHovered={setHovered}
/>
))}
</div>
</div>
);
};
const rootElement = document.getElementById("root");
ReactDOM.render(<ListExample />, rootElement);
This is my attempt, with the downside that it requires the rendered children to pass ref correctly:
import React, { useRef, useState, cloneElement, Children, isValidElement } from "react";
export const ArrowKeyListManager: React.FC = ({ children }) => {
const [cursor, setCursor] = useState(0)
const items = useRef<HTMLElement[]>([])
const onKeyDown = (e) => {
let newCursor = 0
if (e.key === 'ArrowDown') {
newCursor = Math.min(cursor + 1, items.current.length - 1)
} else if (e.key === 'ArrowUp') {
newCursor = Math.max(0, cursor - 1)
}
setCursor(newCursor)
const node = items.current[newCursor]
node?.focus()
}
return (
<div onKeyDown={onKeyDown} {...props}>
{Children.map(children, (child, index) => {
if (isValidElement(child)) {
return cloneElement(child, {
ref: (n: HTMLElement) => {
items.current[index] = n
},
})
}
})}
</div>
)
}
Usage:
function App() {
return (
<ArrowKeyListManager>
<button onClick={() => alert('first')}>First</button>
<button onClick={() => alert('second')}>Second</button>
<button onClick={() => alert('third')}>third</button>
</ArrowKeyListManager>
);
}
It's a list with children that can be navigated by pressing the left-right & up-down key bindings.
Recipe.
Create an Array of Objects that will be used as a list using a map function on the data.
Create a useEffect and add an Eventlistener to listen for keydown actions in the window.
Create handleKeyDown function in order to configure the navigation behaviour by tracking the key that was pressed, use their keycodes fo that.
keyup: e.keyCode === 38
keydown: e.keyCode === 40
keyright: e.keyCode === 39
keyleft: e.keyCode === 37
Add State
let [activeMainMenu, setActiveMainMenu] = useState(-1);
let [activeSubMenu, setActiveSubMenu] = useState(-1);
Render by Mapping through the Array of objects
<ul ref={WrapperRef}>
{navigationItems.map((navigationItem, Mainindex) => {
return (
<li key={Mainindex}>
{activeMainMenu === Mainindex
? "active"
: navigationItem.navigationCategory}
<ul>
{navigationItem.navigationSubCategories &&
navigationItem.navigationSubCategories.map(
(navigationSubcategory, index) => {
return (
<li key={index}>
{activeSubMenu === index
? "active"
: navigationSubcategory.subCategory}
</li>
);
}
)}
</ul>
</li>
);
})}
</ul>
Find the above solution in the following link:
https://codesandbox.io/s/nested-list-accessible-with-keys-9pm3i1?file=/src/App.js:2811-3796

Categories

Resources