diff --git a/src/containers/App.jsx b/src/containers/App.jsx index 2ed4b68..f5f7a50 100644 --- a/src/containers/App.jsx +++ b/src/containers/App.jsx @@ -2,26 +2,19 @@ import React from "react"; import { CssBaseline, ThemeProvider, Container, Box } from "@material-ui/core"; import { ThemeProvider as StyledComponentsThemeProvider } from "styled-components"; import { - useFilters, useTitle, + useTabs, theme, - useShare, - useCalculation, } from "../utils"; -import { Title, Filter, Footer } from "../containers"; -import { ShareDialog, Chart, Table } from "../components"; +import { Title, Footer, IslandTabs, Calculator } from "../containers"; const App = () => { useTitle(); - const { inputFilters, filters, saveFilters } = useFilters(); - const { - onCloseShareModal, - showShareDialog, - openShareDialog, - shareFilters, - } = useShare(filters); + const {tabs, addTab, deleteTab, value, handleTabChange} = useTabs(); - const result = useCalculation({ filters }); + const panelMarkup = tabs.map((tab, index) => ( + + )); return ( @@ -30,21 +23,17 @@ const App = () => { <Box mx={[-1.5, 0]}> - <Filter - filters={inputFilters} - onChange={saveFilters} - openShareDialog={openShareDialog} - /> - <Chart {...result} /> - <Table {...result} /> - <Footer /> - </Box> + <IslandTabs + tabs={tabs} + value={value} + onAdd={addTab} + onDelete={deleteTab} + onChange={handleTabChange} + /> + {panelMarkup} + <Footer /> + </Box> </Container> - <ShareDialog - open={showShareDialog} - filters={shareFilters} - onClose={onCloseShareModal} - /> </StyledComponentsThemeProvider> </ThemeProvider> ); diff --git a/src/containers/Calculator.jsx b/src/containers/Calculator.jsx new file mode 100644 index 0000000..78db731 --- /dev/null +++ b/src/containers/Calculator.jsx @@ -0,0 +1,41 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { useFilters, useShare, useCalculation } from "../utils"; +import { Filter, TabPanel } from "../containers"; +import { ShareDialog, Chart, Table } from "../components"; + +const Calculator = ({ value, index, filterKey }) => { + const { inputFilters, filters, saveFilters } = useFilters(filterKey); + const { + onCloseShareModal, + showShareDialog, + openShareDialog, + shareFilters, + } = useShare(filters); + const result = useCalculation({ filters }); + + return ( + <TabPanel value={value} index={index}> + <Filter + filters={inputFilters} + onChange={saveFilters} + openShareDialog={openShareDialog} + /> + <Chart {...result} /> + <Table {...result} /> + <ShareDialog + open={showShareDialog} + filters={shareFilters} + onClose={onCloseShareModal} + /> + </TabPanel> + ); +}; + +Calculator.propTypes = { + value: PropTypes.number.isRequired, + index: PropTypes.number.isRequired, + filterKey: PropTypes.string.isRequired, +}; + +export default Calculator; diff --git a/src/containers/IslandTabs.jsx b/src/containers/IslandTabs.jsx new file mode 100644 index 0000000..e0630de --- /dev/null +++ b/src/containers/IslandTabs.jsx @@ -0,0 +1,87 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { useTranslation } from "react-i18next"; +import { makeStyles } from "@material-ui/styles"; +import { Tab, Tabs } from "@material-ui/core"; +import { Add, Close } from "@material-ui/icons"; + +const useTabsStyles = makeStyles(({ spacing }) => ({ + root: { + marginLeft: spacing(2), + }, + indicator: { + backgroundColor: "#18A558", + }, +})); + +const useTabStyles = makeStyles(({ breakpoints, spacing }) => ({ + root: { + textTransform: "initial", + padding: spacing(-1, 2), + [breakpoints.up("md")]: { + minWidth: 120, + }, + "&:hover": { + backgroundColor: "rgba(13, 152, 186, 0.1)", + "& .MuiTab-label": { + color: "#18A558", + }, + }, + "&$selected": { + "& *": { + color: "#18A558", + }, + }, + }, + selected: {}, + textColorInherit: { + opacity: 1, + }, + wrapper: { + flexDirection: "row-reverse", + letterSpacing: 0.5, + '& svg, .material-icons': { + marginLeft: 12, + marginTop: 7, + }, + }, +})); + +const IslandTabs = ({ tabs, onAdd, onDelete, onChange, value }) => { + const { t } = useTranslation() + const tabClasses = useTabStyles(); + const tabsClasses = useTabsStyles(); + + return ( + <Tabs variant="scrollable" scrollButtons="auto" value={value} onChange={onChange} classes={tabsClasses}> + {tabs.map((tab, index) => ( + <Tab + key={tab.key} + label={`${t("Island")} ${tab.id + 1}`} + disableRipple + icon={value === index && <Close id={tab.id} onClick={onDelete} fontSize="small" />} + classes={{ + ...tabClasses, + wrapper: `${tabClasses.wrapper} MuiTab-label`, + }} + /> + ))} + <Tab label={t("Add Island")} classes={tabClasses} icon={<Add onClick={onAdd} />} /> + </Tabs> + ); +}; + +IslandTabs.propTypes = { + tabs: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.number.isRequired, + key: PropTypes.string.isRequired, + }), + ), + value: PropTypes.number.isRequired, + onChange: PropTypes.func.isRequired, + onAdd: PropTypes.func.isRequired, + onDelete: PropTypes.func.isRequired, +}; + +export default IslandTabs; diff --git a/src/containers/TabPanel.jsx b/src/containers/TabPanel.jsx new file mode 100644 index 0000000..8e5b2f9 --- /dev/null +++ b/src/containers/TabPanel.jsx @@ -0,0 +1,24 @@ +import React from "react"; +import PropTypes from "prop-types"; + +// Manual implementation of https://material-ui.com/api/tab-panel/ +// TabPanel not yet available in @material-ui/core +const TabPanel = ({ value, index, children }) => { + return ( + <div + role="tabpanel" + hidden={value !== index} + id={`simple-tabpanel-${index}`} + > + {value === index && children} + </div> + ); +}; + +TabPanel.propTypes = { + value: PropTypes.number.isRequired, + index: PropTypes.number.isRequired, + children: PropTypes.node, +}; + +export default TabPanel; diff --git a/src/containers/index.js b/src/containers/index.js index cfd2c46..189872b 100644 --- a/src/containers/index.js +++ b/src/containers/index.js @@ -1,4 +1,7 @@ export { default as App } from "./App"; +export { default as IslandTabs } from "./IslandTabs"; +export { default as TabPanel } from "./TabPanel"; +export { default as Calculator } from "./Calculator"; export { default as Filter } from "./Filter"; export { default as Footer } from "./Footer"; export { default as Localizer } from "./Localizer"; diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 3d1dbd1..3aaf024 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -33,5 +33,7 @@ "Chance": "Chance", "Pattern": "Pattern", "patternNames": "Fluctuating_High Spike_Decreasing_Small Spike", - "All Patterns": "All Patterns" + "All Patterns": "All Patterns", + "Add Island": "Add Island", + "Island": "Island" } diff --git a/src/utils/index.js b/src/utils/index.js index 2cf4679..37a6fa5 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -5,3 +5,4 @@ export { default as useShare } from "./useShare"; export { default as useTitle } from "./useTitle"; export { default as useWeekDays } from "./useWeekDays"; export { default as useCalculation } from "./useCalculation"; +export { default as useTabs } from "./useTabs"; diff --git a/src/utils/useFilters.js b/src/utils/useFilters.js index ff8307f..f797b51 100644 --- a/src/utils/useFilters.js +++ b/src/utils/useFilters.js @@ -1,8 +1,8 @@ import { useEffect, useMemo } from "react"; import { useLocalStorage } from "react-use"; -const useFilters = () => { - const [filters, saveFilters] = useLocalStorage("filters", []); +const useFilters = (key) => { + const [filters, saveFilters] = useLocalStorage(key, []); // Array of strings const inputFilters = useMemo( diff --git a/src/utils/useTabs.js b/src/utils/useTabs.js new file mode 100644 index 0000000..6cc7711 --- /dev/null +++ b/src/utils/useTabs.js @@ -0,0 +1,79 @@ +import { useState, useEffect, useCallback } from "react"; +import { useLocalStorage } from "react-use"; + +const DEFAULT_TAB_LIST = [ + { + id: 0, + key: "filters-0", + }, +]; + +const useTabs = () => { + const [value, setValue] = useState(0); + const [tabs, saveTabs] = useLocalStorage("tablist", DEFAULT_TAB_LIST); + + useEffect(() => { + if (!Array.isArray(tabs)) { + saveTabs(DEFAULT_TAB_LIST); + } + }, [tabs, saveTabs]); + + const addTab = useCallback(() => { + let id = 0 + for (id = 0; id < tabs.length; id++) { + if (tabs[id].id !== id) { + break; + } + } + setValue(id); + saveTabs([...tabs.slice(0, id), { + id, + key: `filters-${id}`, + }, ...tabs.slice(id)]); + }, [tabs, saveTabs]); + + const deleteTab = useCallback((event) => { + // Prevent MaterialUI from switching tabs + event.stopPropagation(); + + // Prevent deleting the last tab + if (tabs.length === 1) { + return; + } + + let deletedTabIndex = 0; + const tabId = parseInt(event.target.id, 10); + + const tabList = tabs.filter((tab, index) => { + if (tab.id === tabId) { + deletedTabIndex = index; + localStorage.removeItem(tab.key); + } + return tab.id !== tabId; + }); + + if (deletedTabIndex !== 0) { + setValue(deletedTabIndex - 1) + } + + saveTabs(tabList); + }, [tabs, saveTabs]); + + const handleTabChange = useCallback((_event, newValue) => { + if (newValue === tabs.length) { + addTab(); + } else { + setValue(newValue); + } + }, [tabs, addTab]); + + return { + value, + tabs, + addTab, + deleteTab, + handleTabChange, + }; +}; + +export default useTabs;