My autocomplete component is pulling a list of books from an API. I am rendering them as options in the Autocomplete component, and also outputting them as a list at the bottom of the page for debugging purposes. Also outputting the JSON from the API.
Two issues seem to be intertwined. First, the Autocomplete options don't seem to be all rendering. There are up to 10 results (limited to 10 by the API call) and they're all rending in the list below the autocomplete, but not in the list of options in the Autocomplete. Second, when the API is being called (like the time between changing the text from "abc" to "abcd") it shows "No options" rather than displaying the options from just "abc".
In the sandbox code here try typing slowly - 1 2 3 4 5 6 - you'll see that there are results in the <ul> but not in the <Autocomplete>.
Any ideas on why this (or maybe both separately) are happening?
Thanks!
Code from sandbox:
import React, { useState, useEffect } from "react";
import Autocomplete from "#material-ui/lab/Autocomplete";
import {
makeStyles,
Typography,
Popper,
InputAdornment,
TextField,
Card,
CardContent,
CircularProgress,
Grid,
Container
} from "#material-ui/core";
import MenuBookIcon from "#material-ui/icons/MenuBook";
import moment from "moment";
// sample ISBN: 9781603090254
function isbnMatch(isbn) {
const str = String(isbn).replace(/[^0-9a-zA-Z]/, ""); // strip out everything except alphanumeric
const r = /^[0-9]{13}$|^[0-9]{10}$|^[0-9]{9}[Xx]$/; // set the regex for 10, 13, or 9+X characters
return str.match(r);
// return str.match(/^[0-9]{3}$|^[0-9]{3}$|^[0-9]{2}[Xx]$/);
}
const useStyles = makeStyles((theme) => ({
adornedEnd: {
backgroundColor: "inherit",
height: "2.4rem",
maxHeight: "3rem"
},
popper: {
maxWidth: "fit-content"
}
}));
export default function ISBNAutocomplete() {
console.log(`Starting ISBNAutocomplete`);
const classes = useStyles();
const [options, setOptions] = useState([]);
const [inputText, setInputText] = useState("");
const [open, setOpen] = useState(false);
const loading = open && options.length === 0 && inputText.length > 0;
useEffect(() => {
async function fetchData(searchText) {
const isbn = isbnMatch(searchText);
//console.log(`searchText = ${searchText}`);
//console.log(`isbnMatch(searchText) = ${isbn}`);
const fetchString = `https://www.googleapis.com/books/v1/volumes?maxResults=10&q=${
isbn ? "isbn:" + isbn : searchText
}&projection=full`;
//console.log(fetchString);
const res = await fetch(fetchString);
const json = await res.json();
//console.log(JSON.stringify(json, null, 4));
json && json.items ? setOptions(json.items) : setOptions([]);
}
if (inputText?.length > 0) {
// only search the API if there is something in the text box
fetchData(inputText);
} else {
setOptions([]);
setOpen(false);
}
}, [inputText, setOptions]);
const styles = (theme) => ({
popper: {
maxWidth: "fit-content",
overflow: "hidden"
}
});
const OptionsPopper = function (props) {
return <Popper {...props} style={styles.popper} placement="bottom-start" />;
};
console.log(`Rendering ISBNAutocomplete`);
return (
<>
<Container>
<h1>Autocomplete</h1>
<Autocomplete
id="isbnSearch"
options={options}
open={open}
//noOptionsText=""
style={{ width: 400 }}
PopperComponent={OptionsPopper}
onOpen={() => {
setOpen(true);
}}
onClose={() => {
setOpen(false);
}}
onChange={(event, value) => {
console.log("ONCHANGE!");
console.log(`value: ${JSON.stringify(value, null, 4)}`);
}}
onMouseDownCapture={(event) => {
event.stopPropagation();
console.log("STOPPED PROPAGATION");
}}
onInputChange={(event, newValue) => {
// text box value changed
//console.log("onInputChange start");
setInputText(newValue);
// if ((newValue).length > 3) { setInputText(newValue); }
// else { setOptions([]); }
//console.log("onInputChange end");
}}
getOptionLabel={(option) =>
option.volumeInfo && option.volumeInfo.title
? option.volumeInfo.title
: "Unknown Title"
}
getOptionSelected={(option, value) => option.id === value.id}
renderOption={(option) => {
console.log(`OPTIONS LENGTH: ${options.length}`);
return (
<Card>
<CardContent>
<Grid container>
<Grid item xs={4}>
{option.volumeInfo &&
option.volumeInfo.imageLinks &&
option.volumeInfo.imageLinks.smallThumbnail ? (
<img
src={option.volumeInfo.imageLinks.smallThumbnail}
width="50"
height="50"
/>
) : (
<MenuBookIcon size="50" />
)}
</Grid>
<Grid item xs={8}>
<Typography variant="h5">
{option.volumeInfo.title}
</Typography>
<Typography variant="h6">
(
{new moment(option.volumeInfo.publishedDate).isValid()
? new moment(option.volumeInfo.publishedDate).format(
"yyyy"
)
: option.volumeInfo.publishedDate}
)
</Typography>
</Grid>
</Grid>
</CardContent>
</Card>
);
}}
renderInput={(params) => (
<>
<TextField
{...params}
label="ISBN - 10 or 13 digit"
//"Search for a book"
variant="outlined"
value={inputText}
InputProps={{
...params.InputProps, // make sure the "InputProps" is same case - not "inputProps"
autoComplete: "new-password", // forces no auto-complete history
endAdornment: (
<InputAdornment
position="end"
color="inherit"
className={classes.adornedEnd}
>
<>
{loading ? (
<CircularProgress color="secondary" size={"2rem"} />
) : null}
</>
{/* <>{<CircularProgress color="secondary" size={"2rem"} />}</> */}
</InputAdornment>
),
style: {
paddingRight: "5px"
}
}}
/>
</>
)}
/>
<ul>
{options &&
options.map((item) => (
<li key={item.id}>{item.volumeInfo.title}</li>
))}
</ul>
<span>
inputText: <pre>{inputText && inputText}</pre>
</span>
<span>
<pre>
{options && JSON.stringify(options, null, 3).substr(0, 500)}
</pre>
</span>
<span>Sample ISBN: 9781603090254</span>
</Container>
</>
);
}
By default, Autocomplete filters the current options array by the current input value. In use cases where the options are static, this doesn't cause any issue. Even when the options are asynchronously loaded, this only causes an issue if the number of query matches is limited. In your case, the fetch is executed with maxResults=10 so only 10 matches are returned at most. So if you are typing "123" slowly, typing "1" brings back 10 matches for "1" and none of those matches contain "12" so once you type the "2", none of those 10 options match the new input value, so it gets filtered to an empty array and the "No options" text is displayed until the fetch for "12" completes. If you now delete the "2", you won't see the problem repeat because all of the options for "12" also contain "1", so after filtering by the input value there are still options displayed. You also wouldn't see this problem if all of the matches for "1" had been returned, because then some of those options would also contain "12" so when you type the "2" the options would just be filtered down to that subset.
Fortunately, it is easy to address this. If you want Autocomplete to always show the options you have provided it (on the assumption that you will modify the options prop asynchronously based on changes to the input value), you can override its filterOptions function so that it doesn't do any filtering:
<Autocomplete
id="isbnSearch"
options={options}
filterOptions={(options) => options}
open={open}
...
Autocomplete custom filter documentation: https://material-ui.com/components/autocomplete/#custom-filter
Related
I'm working on a simple todos app and I'm stuck at a point. Basically, my array filtering is not working. It's not doing anything in fact and I couldn't find out why. I'm using Material UI in the app and I'm suspecting there is something related to that but couldn't figure out entirely.
I'm trying to delete one todo by clicking the trash icon which triggers "deleteTodo" function. But it's not deleting it from the todos. Actually, as I said it's doing nothing. I'm keeeping my todos in the localStorage.
Here is my delete one todo function:
function deleteTodo(id) {
setTodos(todos.filter((todo,i,arr) => {
console.log("id:", id)
console.log("todo.id:",todo.id)
console.log("are equal:", todo.id === id)
console.log(i, arr)
return (todo.id !== id)
}))
}
console output:
[Log] id: – "37dbcd88d5a"
[Log] todo.id: – "37dbcd88d5a"
[Log] are equal: – true
[Log] 1 – [{text: "two", done: false, id: "7dbcd88d5a3"}, {text: "one", done: false, id: "37dbcd88d5a"}] (2)
my component as a whole:
import { uid } from 'uid'
import { useState , useEffect, useLayoutEffect, useRef } from "react"
import { Card, CardContent, Modal, List, ListItem, Box, Button, IconButton, TextField, Typography } from "#mui/material"
import styles from "../styles/Todos.module.css"
import { TransitionGroup } from 'react-transition-group';
import ReportProblemIcon from '#mui/icons-material/ReportProblem';
import DeleteIcon from '#mui/icons-material/Delete';
import EditIcon from '#mui/icons-material/Edit';
export default function Todos () {
const [ text, setText ] = useState("")
const [ todos, setTodos ] = useState([])
const [ showClearTodosModal, setShowClearTodosModal] = useState(false)
const inputRef = useRef()
useEffect(() => {
// localStorage.todos && console.log('b1:',JSON.parse(localStorage.todos))
if (localStorage.todos) {
// localStorage.todos && console.log(JSON.parse(localStorage.todos))
setTodos(JSON.parse(localStorage.todos))
} else {
localStorage.todos = []
}
// localStorage.todos && console.log('a1:',JSON.parse(localStorage.todos))
}, [])
useEffect(() => {
if (todos && todos.length > 0) {
localStorage.todos && console.log('b2:',JSON.parse(localStorage.todos))
localStorage.setItem("todos", JSON.stringify(todos))
localStorage.todos && console.log('a2:',JSON.parse(localStorage.todos))
}
}, [todos])
function handleSubmit(e) {
e.preventDefault()
const id = uid()
setTodos([{text, done:false, id:id}, ...todos])
setText("")
}
function markDone(e) {
setTodos(todos.map(todo => {
if (e.target.innerText === todo.text) {
return {...todo, done:!todo.done}
} else {
return todo
}
}))
}
function deleteTodo(id) {
setTodos(todos.filter((todo,i,arr) => {
console.log("id:", id)
console.log("todo.id:",todo.id)
console.log("are equal:", todo.id === id)
console.log(i, arr)
return (todo.id !== id)
}))
}
function deleteAll() {
setTodos([])
setShowClearTodosModal(false)
localStorage.removeItem("todos")
}
return (
<Box>
<form
onSubmit={handleSubmit}
>
<TextField
className={styles.entryfield}
label="Add a todo"
autoFocus
value={text}
onChange={(e) => setText(e.target.value)}
ref={inputRef}
/>
</form>
<Button
color="primary"
aria-label="upload picture"
component="span"
className={styles.clearall}
onClick={() => setShowClearTodosModal(true)}
startIcon={<ReportProblemIcon />}
>
Delete All Todos
</Button>
<Modal
open={showClearTodosModal}
onClose={() => setShowClearTodosModal(false)}
aria-labelledby="delete all todos"
aria-describedby="delete all todos"
className={styles.deleteallmodal}
>
<Box className={styles.modalbox}>
<Typography id="modal-modal-title" variant="h6" component="h2">
Delete all todos?
</Typography>
<Typography id="modal-modal-description" sx={{ mt: 2 }}>
Are you sure you want to delete all todos? This action is irreversable and you will lose all of your todos.
</Typography>
<Button
onClick={() => setShowClearTodosModal(false)}
variant="contained"
sx={{m:1}}
>
Nah, don't delete my todos
</Button>
<Button
onClick={deleteAll}
variant="contained"
startIcon={<ReportProblemIcon />}
sx={{
m:1,
color: "maroon",
}}
>
Yes I'm sure delete all of them
</Button>
</Box>
</Modal>
<List>
{todos && todos.map(todo =>(
<ListItem
key={todo.id}
onClick={markDone}
>
<Card className={styles.card}
>
<IconButton
className={styles.icons}
onClick={() => {deleteTodo(todo.id)}}
aria-label="delete"
>
<DeleteIcon fontSize="small"/>
</IconButton>
<IconButton
className={styles.icons}
aria-label="edit"
>
<EditIcon fontSize="small"/>
</IconButton>
<Typography
variant="body1"
style= {{
color: todo.done ? "#555" : "",
margin: 10,
}}
>
{todo.text}
</Typography>
</Card>
</ListItem>
)
)}
</List>
</Box>
)
}
Here is the working codesandbox of your component. Please align your code with my example and be sure that you do not have any other wrong implementations in your code
https://codesandbox.io/s/heuristic-shadow-7lz1o0?file=/src/App.js
import { useState } from "react";
import "./styles.css";
export default function App() {
const [todos, setTodos] = useState([
{ text: "two", done: false, id: "7dbcd88d5a3" },
{ text: "one", done: false, id: "37dbcd88d5a" },
{ text: "one", done: false, id: "643dbcd88d5a" }
]);
function deleteTodo(id) {
setTodos(
todos.filter((todo, i, arr) => {
console.log("id:", id);
console.log("todo.id:", todo.id);
console.log("are equal:", todo.id === id);
console.log(i, arr);
return todo.id !== id;
})
);
}
return (
<div className="App">
{todos &&
todos.map((todo) => (
<div key={todo.id}>
<div>
<button
onClick={() => {
deleteTodo(todo.id);
}}
aria-label="delete"
>
{todo.id}
</button>
</div>
</div>
))}
</div>
);
}
let testArray = [{text: "two", done: false, id: "7dbcd88d5a3"}, {text: "one", done: false, id: "37dbcd88d5a"},
{text: "one", done: false, id: "643dbcd88d5a"}]
function deleteTodo(id) {
console.log(testArray.filter((todo,i,arr) => {
return (todo.id !== id)}))
}
deleteTodo('7dbcd88d5a3');
Thanks for sharing your full code! It was helpful to understand what was happening.
The culprit line that's causing you the headache is here:
<ListItem
key={todo.id}
onClick={markDone}
>
<Card className={styles.card}
>
<IconButton
className={styles.icons}
onClick={() => {deleteTodo(todo.id)}}
aria-label="delete"
>
Specifically, onClick={markDone} on ListItem. You put the markDone function on the whole list item. So clicking on the IconButton causes the ListItem event to be triggered too.
I know you have the e.preventDefault() but unfortunately the order is reversed here with event bubbling (starts inside out and you need stopPropagation instead of preventDefault): I put some logs in deleteTodo and markDone to indicate the start and end of each function and this is the order. This was the output:
Todos.tsx:82 deleteTodo start
Todos.tsx:86 deleteTodo end
Todos.tsx:66 markdone start
Todos.tsx:76 markdone end
So what's likely happening is react is either doing the setTodos sequentially or batching them, but either way it looks like the setTodos from markDone is taking precedence, and the setTodo there is just a map of the same values again. Hence why clicking the delete button leads to no changes and the same todo items remaining.
Possible solutions could include
a) Add the event as a parameter to deleteTodo and run e.stopPropagation() at the top of the function to prevent event bubbling further.
function deleteTodo(e, id) {
e.stopPropagation();
console.log('deleteTodo start');
setTodos(
todos.filter((todo, i, arr) => {
return todo.id !== id;
})
);
console.log('deleteTodo end');
}
and setup your IconButton:
<IconButton
onClick={(e) => {
deleteTodo(e, todo.id);
}}
This should help stop the event from bubbling further up (I confirmed this to be working locally).
b) You could remove the onClick={markDone} and add a separate button to do the mark done action (removing the onClick is confirmed to make your delete method work)
c) You keep the markDone but do some additional conditional checking on the e event target to see if the delete button was hit - if it was, return early in the function.
Still new to ReactJS.
I have 3 JS pages: Home, Create, Edit.
From Home, you can navigate to the Create and Edit pages.
You can access the Create page anytime, but you need to call an API to populate some data before you can access the Edit page.
All 3 pages are using the same component, FormEntry. As its name, it generates basically a form input. Within this component, there are 2 functions: Search and AddEdit. Home is using the former, Create and Edit are using the later.
The flow is as such where when you click on the Create button, this will direct you to the Create page. The Create page will then display the form.
However, if you click on the Search button, this will call an API and generate data in a table. Each table row is clickable and clicking on them will direct you to the Edit page. For reusability, I parse in some values using props that, in theory, should populate the form fields based on which row I clicked on.
The issue I'm having is that though the value gets parsed in, the form field is not displaying the correct data. To be specific, the data from the responseData I parsed into the component is not displaying. And even if it does display, it's returning as 'undefined'.
What am I doing wrong?
Home.js
function HomePage() {
const [responseData, setData] = useState([]);
const navigateData = useNavigate();
function navigateToEdit(event){
navigateData({insert URL here}+event.id);
}
function getSearchData2(allData){
( allData.propRefNum !== "" ||
allData.client !== "" ||
allData.appSys !== "" ||
allData.status !== "" ? AxiosCall.searchProposal(JSON.stringify(allData)): AxiosCall.getProposals()
)
.then(
(result) => {
setData(result.data);
}
);
}
return (
<>
<div style={{ height: 400, width: '100%' }}>
<div style={{ display: 'flex', height: '100%' }}>
<div style={{ flexGrow: 1 }}>
<DataGrid onRowClick={navigateToEdit} rows={dataRowObjs} columns={dataColObjs} headerAlign="center" disableColumnFilter />
</div>
</div>
</div>
</>
);
}
export default HomePage;
Edit.js
function EditPage() {
const [responseData, setData] = useState([]);
const { id } = useParams();
useEffect(() => {
const apiData = {
id: id
}
AxiosCall.getProposal(JSON.stringify(apiData))
.then(
(result) => {
setData(result.data);
}
);
},[]);
function getEditData(allData){
fetch({insert URL here}).then(
(result) => {
setData(result);
});
}
return <FormEntry.AddEditFormEntry title="Edit Proposal" defaultDataValue={responseData} responseInputData={getEditData} />
}
export default EditPage;
FormEntry component; AddEditForm
function AddEditFormEntry(props){
const propRefNumRef = useRef();
const descRef = useRef();
const clientRef = useRef();
const appSysRef = useRef();
const statusRef = useRef();
const remarkRef = useRef();
const vendorRef = useRef();
const { register, formState: { errors }, handleSubmit } = useForm();
function submitData(data){
//event.preventDefault();
const propRefNum = propRefNumRef.current.value;
const desc = descRef.current.value;
const client = clientRef.current.value;
const appSys = appSysRef.current.value;
const status = statusRef.current.value;
const remark = remarkRef.current.value;
const vendor = vendorRef.current.value;
const allData = {
propRefNum: propRefNum,
desc: desc,
client: client,
appSys: appSys,
status: status,
remark: remark,
vendor: vendor,
}
props.responseInputData(allData);
}
let defaultRefNum = props.defaultDataValue?.refNum; - **Note A: this is the line in question. When I console.log this variable, it displays the data correctly**
return(
<>
<form className="formEntry" onSubmit={handleSubmit(submitData)}>
<div style={{ display: 'flex'}} >
<div align="left" >
<RouterLink to={insert URL here} >
<IconButton aria-label="search" color="primary" >
<SkipPreviousIcon />
</IconButton>
</RouterLink >
</div>
<h1 style={{ flexGrow: 1, marginTop: -4 }} >{props.title}</h1>
<div align="right">
<IconButton aria-label="search" color="primary" type="submit" >
<SaveIcon />
</IconButton>
<IconButton aria-label="search" color="primary" type="submit" >
<SaveIcon />
</IconButton>
</div>
</div>
<br/>
<Stack justifyContent="center" direction="row" spacing={2} >
<Stack justifyContent="center" direction="column" spacing={2} >
<FieldEntry.TextEntry required="true" label="Proposal Reference Number" type="text" id="input_propRefNum" name="propRefNum" propsRef={propRefNumRef} value={defaultRefNum} />
**Referring to Note A above, I want to populate this field above. I am getting 'undefined', if not blank. If I am using the Create function, blank/undefined is expected. The Edit function is supposed to populate something here **
<FieldEntry.TextEntry label="Description" type="text" id="input_desc" name="desc" propsRef={descRef} value={props.defaultDataValue?.description} />
<FieldEntry.TextEntry required="true" label="Client" type="text" id="input_client" name="client" propsRef={clientRef} />
<FieldEntry.TextEntry required="true" label="Application System" type="text" id="input_appSys" name="appSys" propsRef={appSysRef} value={props.defaultDataValue?.appSystem} />
</Stack>
</Stack>
<br/>
</form>
</>
)
}
Note: I've removed a number of codes that does not pertain to the matter, to keep the sample code small. Rest assured that aside from my issue, everything is working as expected
I think a simpler example(which I just found out will have the same issue) would be
const [defaultRefNum, setRefNum] = useState("");
const [counter, setCounter] = useState(0);
let testValue = props.defaultDataValue?.refNum
useEffect(() => {
console.log("testValue2:",testValue)
if (props.defaultDataValue != null){
console.log("is not Null")
setCounter(c => c+1);
}
else{
console.log("is Null")
}
},[testValue]);
console.log("counter:",counter)
and
<FieldEntry.TextEntry value={counter} />
console.log output - counter: 2
Value in TextEntry: 0
I would assume the value in TextEntry should've outputted to be 2.
I have a list of medias and my goal is to be able to show the currently playing media.
To do so, I compare the playing media ID with the one from the list to apply the correct style.
My issue is that when clicking on another item, all items re-render because they have a dependency on the playing media which is observable.
class AppStore {
...
get playingVideo() {
if (!this.player.videoId || this.player.isStopped) {
return null;
}
return this.videos[this.player.videoId];
}
}
const DraggableMediaItem = observer(({ video, index }) => {
const store = useAppStore();
const isMediaActive = computed(
() => store.playingVideo && video.id === store.playingVideo.id
).get();
console.log("RENDER", video.id);
const onMediaClicked = (media) => {
if (!isMediaActive) {
playerAPI.playMedia(media.id).catch(snackBarHandler(store));
return;
}
playerAPI.pauseMedia().catch(snackBarHandler(store));
};
let activeMediaProps = {};
if (isMediaActive) {
activeMediaProps = {
autoFocus: true,
sx: { backgroundColor: "rgba(246,250,254,1)" },
};
}
return (
<Draggable draggableId={video.id} index={index}>
{(provided, snapshot) => (
<ListItem
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={getItemStyle(
snapshot.isDragging,
provided.draggableProps.style
)}
button
disableRipple
{...activeMediaProps}
onClick={() => onMediaClicked(video)}
>
<Stack direction="column" spacing={1} sx={{ width: "100%" }}>
<Stack direction="row" alignItems="center">
<ListItemAvatar>
<MediaAvatar video={video} />
</ListItemAvatar>
<ListItemText primary={video.title} />
<ListItemText
primary={durationToHMS(video.duration)}
sx={{
textAlign: "right",
minWidth: "max-content",
marginLeft: "8px",
}}
/>
</Stack>
</Stack>
</ListItem>
)}
</Draggable>
);
});
I thought making isMediaActive a computed value would prevent that, but since the value the computation is based on changes, it triggers an update.
Is it possible to only re-render when the computed value changes ?
[EDIT]
Following #danila's comment, I cleaned up my code and injected the isActive parameter. However, I must still be missing something, since the List doesn't re-render when the player's video changes.
That would be the current pseudocode:
const MediaItem = observer(({ isActive }) => {
let activeMediaProps = {};
if (isActive) {
activeMediaProps = {
sx: { backgroundColor: "rgba(246,250,254,1)" },
};
}
return <ListItem {...activeMediaProps}> ... </ListItem>;
});
const Playlist = observer(() => {
const store = useAppStore();
const items = store.playlist;
return (
<List>
{items.map((item) => (
<MediaItem isActive={item.id === store.player.videoId} />
))}
</List>
);
});
[EDIT 2]
Code sandbox link with a working example:
https://codesandbox.io/s/silent-lake-2lvdc?file=/src/App.js
Thank you in advance for your help and time.
First of all you can't use computed like that. In most cases computed should be used like a property in your store. Similar to observable.
As for the question, if you don't want items to rerender you could provide this flag through props, something like that in pseudocode
const List = observer(() => {
return (
<div>
{items.map(item => (
<Item isMediaActive={store.playingVideo && item.id === store.playingVideo.id} />
))}
</div>
)
})
It is also better to have that list as "standalone" component, don't just render items inside your whole view. More info here https://mobx.js.org/react-optimizations.html#render-lists-in-dedicated-components
EDIT:
There is also another way, which is actually "more MobX" way of doing things, is to have isPlaying flag in the item object itself. But that might require you to change how you work with your data, so the first example is probably easier if you have already setup everything else.
With flag on the item you don't even need to do anything else, you just check if it is active or not and MobX will do everything else. Only 2 items will rerender when you change the flag. The action in your store could look like that:
playItem(itemToPlay) {
this.items.find(item => item.isPlaying)?.isPlaying = false
itemToPlay.isPlaying = true
}
Instead of the actual numeric values, can I put in words?
Can I control which values get labels?
Can I even insert icons?
Screenshot is from: https://material-ui.com/components/slider/#customized-sliders.
The valueLabelFormat prop allows you to customize the content of the value label for a given value. The function you provide can return content that is as complex as you need it to be. My example below includes the value, some text, and an icon. Since that won't fit in the default bubble look for the value label, I also used the ValueLabelComponent prop to render the value label using Tooltip instead since it will resize as needed for the content within it.
import Slider from "#material-ui/core/Slider";
import Tooltip from "#material-ui/core/Tooltip";
import { useState } from "react";
import BrightnessLowIcon from "#material-ui/icons/BrightnessLow";
import BrightnessMediumIcon from "#material-ui/icons/BrightnessMedium";
import BrightnessHighIcon from "#material-ui/icons/BrightnessHigh";
function ValueLabelComponent(props) {
const { children, open, value } = props;
return (
<Tooltip open={open} interactive enterTouchDelay={0} title={value}>
{children}
</Tooltip>
);
}
export default function App() {
const [value, setValue] = useState(0);
return (
<div>
<Slider
min={-10}
max={10}
defaultValue={0}
step={0.1}
valueLabelDisplay="on"
ValueLabelComponent={ValueLabelComponent}
valueLabelFormat={(value) => {
return (
<div style={{ textAlign: "center" }}>
{value}
<br />
{value < -5 ? "Low" : value > 5 ? "High" : "Medium"}
<br />
{value < -5 ? (
<BrightnessLowIcon style={{ color: "red" }} />
) : value > 5 ? (
<BrightnessHighIcon style={{ color: "lime" }} />
) : (
<BrightnessMediumIcon style={{ color: "yellow" }} />
)}
</div>
);
}}
value={value}
onChange={(event, value) => setValue(value)}
/>
</div>
);
}
I want to create a fully controlled dropdown in order to use react-window to show really long list of items in it.
I've checked docs, and there is no any example of controlled dropdown with Dropdown.Item specified.
This is how my component looks like:
<Dropdown
placeholder="Filter Posts"
clearable={true}
search={true}
onChange={this.handleChange}
text={tagOptions[1].value}
value={tagOptions[1].value}
onSearchChange={this.handleChange}
>
<Dropdown.Menu>
{tagOptions.map(option => (
<Dropdown.Item key={option.value} {...option} onClick={this.handleItemClick} />
))}
</Dropdown.Menu>
</Dropdown>;
I've encounter with 2 issues:
Initial value is not appears, I dig into the code, and saw that if i don't pass options property it won't find the given value, therefore, it will not be shown. I can use the text property, but it seems like a hack.
I need to implement handleItemClick by myself, and I see that there is logic in the original handleItemClick.
Any suggestions? did I missed something here?
I've able to hack it around with using ref on the dropdown and passing the original handleItemClick method.
The only downside for now is that keyboard nav is not works :\
Seem like it was not designed to be fully controlled.
https://codesandbox.io/s/ql3q086l5q
The dropdown module simply doesn't have support for controlling it's inner components, that being said this is the closest I've gotten to a controlled dropdown with react-window support. I'm posting it here for anyone in the future that wants a select dropdown with virtualisation without a headache.
VirtualisedDropdown.js
import React, { forwardRef, useCallback, useRef, useState } from "react"
import { Dropdown, Ref } from "semantic-ui-react"
import { FixedSizeList } from "react-window"
import "./VirtualisedDropdown.scss"
const SUI_DROPDOWN_MENU_HEIGHT = 300
const SUI_DROPDOWN_MENU_ITEM_HEIGHT = 37
const VirtualisedDropdown = ({
options, value,
...restProps
}) => {
const dropdownRef = useRef()
const listRef = useRef()
const [open, setOpen] = useState(false)
const OuterDiv = useCallback(({ style, ...props }, ref) => {
const { position, overflow, ...restStyle } = style
return (
<Ref innerRef={ref}>
<Dropdown.Menu open={open} {...props} style={restStyle}>
{props.children}
</Dropdown.Menu>
</Ref>
)
}, [open])
const InnerDiv = useCallback(props => {
return (
<Dropdown.Menu className="inner" open={open} style={{ ...props.style, maxHeight: props.style.height }}>
{props.children}
</Dropdown.Menu>
)
}, [open])
return (
<Dropdown
className="virtualised selection"
onClose={() => setOpen(false)}
onOpen={() => {
setOpen(true)
listRef.current.scrollToItem(options.findIndex(i => i.value === value))
}}
// This causes "Warning: Failed prop type: Prop `children` in `Dropdown` conflicts with props: `options`. They cannot be defined together, choose one or the other."
// but is necessary for some logic to work e.g. the selected item text.
options={options}
ref={dropdownRef}
selectOnNavigation={false}
value={value}
{...restProps}
>
<FixedSizeList
height={options.length * SUI_DROPDOWN_MENU_ITEM_HEIGHT < SUI_DROPDOWN_MENU_HEIGHT ? options.length * SUI_DROPDOWN_MENU_ITEM_HEIGHT + 1 : SUI_DROPDOWN_MENU_HEIGHT}
innerElementType={InnerDiv}
itemCount={options.length}
itemData={{
options,
handleClick: (_e, x) => dropdownRef.current.handleItemClick(_e, x),
selectedIndex: options.findIndex(i => i.value === value),
}}
itemSize={SUI_DROPDOWN_MENU_ITEM_HEIGHT}
outerElementType={forwardRef(OuterDiv)}
ref={listRef}
>
{Row}
</FixedSizeList>
</Dropdown>
)
}
const Row = ({ index, style, data }) => {
const { options, handleClick, selectedIndex } = data
const item = options[index]
return (
<Dropdown.Item
active={index === selectedIndex}
className="ellipsis"
key={item.value}
onClick={handleClick}
selected={index === selectedIndex}
style={style}
title={item.text}
{...item}
/>
)
}
export default VirtualisedDropdown
VirtualisedDropdown.scss
.ui.dropdown.virtualised .menu {
&.inner {
margin: 0 -1px !important;
left: 0;
overflow: initial;
border-radius: 0 !important;
border: 0;
}
> .item {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
}
To solve first problem remove clearable={true} and text={tagOptions[1].value}
What handleItemClick function should do?