Skip to content

Commit

Permalink
icon picker modal
Browse files Browse the repository at this point in the history
  • Loading branch information
youssefezzahi96 committed Jan 7, 2025
1 parent 7ff9964 commit 17309cf
Show file tree
Hide file tree
Showing 5 changed files with 267 additions and 3 deletions.
2 changes: 2 additions & 0 deletions packages/@coorpacademy-components/locales/en/global.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@
"continue_learning": "Continue learning",
"learning_priority_modal_title": "New learning priority",
"learning_priority_modal_description": "Create a learning priority by selecting either one skill, playlist or certification.",
"icon_picker_title": "Change icon",
"icon_picker_description": "Select a new icon for your skill",
"skills_change_focus": "Change skill focus",
"skills_choose_focus": "Choose your focus",
"cancel": "Cancel",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import ButtonLink from '../../atom/button-link';
import style from './style.css';

const BaseModal = (props, context) => {
const {title, description, headerIcon, children, isOpen, footer, onClose} = props;
const {title, description, headerIcon, children, isOpen, footer, onClose, onScroll} = props;
const {skin} = context;

const Footer = useCallback(() => {
Expand Down Expand Up @@ -111,7 +111,9 @@ const BaseModal = (props, context) => {
<Icon iconName="close" backgroundColor="#F4F4F5" size={{faSize: 14, wrapperSize: 28}} />
</div>
</header>
<div className={style.body}>{children}</div>
<div className={style.body} onScroll={onScroll}>
{children}
</div>
<Footer />
</div>
</div>
Expand Down Expand Up @@ -151,7 +153,8 @@ BaseModal.propTypes = {
})
})
]),
onClose: PropTypes.func
onClose: PropTypes.func,
onScroll: PropTypes.func
};

export default BaseModal;
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import React, {useMemo, useState, useCallback, useEffect} from 'react';
import PropTypes from 'prop-types';
import {fas} from '@fortawesome/pro-solid-svg-icons';
import {debounce, map, pipe, get, values, slice, filter} from 'lodash/fp';
import BaseModal from '../base-modal';
import SelectIcon from '../../atom/select-icon';
import Provider from '../../atom/provider';
import SearchForm from '../search-form';
import {COLORS} from '../../variables/colors';
import style from './style.css';

const ICONS_PER_LOAD = 48;

const IconPickerModal = (props, context) => {
const {isOpen, onCancel, onConfirm, onClose} = props;
const {translate} = context;

const [selectedIcon, setSelectedIcon] = useState(null);
const [searchValue, setSearchValue] = useState('');
const [displayedIcons, setDisplayedIcons] = useState([]);
const [currentIndex, setCurrentIndex] = useState(0);

const allIcons = useMemo(() => pipe(values, map(get('iconName')))(fas), []);
const [searchResults, setSearchResults] = useState(allIcons);

const handleCancel = useCallback(() => {
onCancel();
}, [onCancel]);

const handleClose = useCallback(() => {
onClose();
}, [onClose]);

const handleIconClick = useCallback(
index => () => {
setSelectedIcon(prevSelectedIcon => (prevSelectedIcon === index ? null : index));
},
[]
);

const loadMoreIcons = useCallback(() => {
const nextIndex = currentIndex + ICONS_PER_LOAD;
const newIcons = slice(currentIndex, nextIndex, searchResults);
setDisplayedIcons(prevIcons => [...prevIcons, ...newIcons]);
setCurrentIndex(nextIndex);
}, [currentIndex, searchResults]);

useEffect(() => {
setDisplayedIcons(() => slice(0, ICONS_PER_LOAD, searchResults));
setCurrentIndex(ICONS_PER_LOAD);
}, [searchResults]);

const handleScroll = useCallback(
event => {
const {scrollTop, clientHeight, scrollHeight} = event.currentTarget;
if (scrollHeight - scrollTop <= clientHeight + 1) {
loadMoreIcons();
}
},
[loadMoreIcons]
);

const updateSearchResults = useCallback(
query => {
if (!query) {
setSearchResults(allIcons);
} else {
const results = filter(
iconName => iconName.toLowerCase().includes(query.toLowerCase()),
allIcons
);
setSearchResults(results);
}
},
[allIcons]
);

const debouncedSearch = useMemo(() => debounce(300, updateSearchResults), [updateSearchResults]);

const handleSearch = useCallback(
value => {
setSearchValue(value);
debouncedSearch(value);
},
[debouncedSearch]
);

const handleReset = useCallback(() => {
setSearchValue('');
updateSearchResults('');
}, [updateSearchResults]);

const icons = useMemo(
() =>
displayedIcons.map((iconName, index) => (
<SelectIcon
key={`icon-${index}`}
size="responsive"
data-name={`icon-${index}`}
aria-label={`aria icon ${index}`}
faIcon={iconName}
onClick={handleIconClick(index)}
options={{selectionMode: selectedIcon === index ? 'single' : ''}}
/>
)),
[displayedIcons, selectedIcon, handleIconClick]
);

const footer = useMemo(() => {
return {
cancelButton: {
onCancel: handleCancel,
label: translate('cancel')
},
confirmButton: {
onConfirm: () => {
onConfirm(selectedIcon);
setSelectedIcon(null);
onClose();
},
label: translate('confirm'),
iconName: 'plus',
disabled: selectedIcon === null,
color: COLORS.cm_primary_blue
}
};
}, [handleCancel, onConfirm, onClose, translate, selectedIcon]);

if (!isOpen) return null;

return (
<BaseModal
title={translate('icon_picker_title')}
description={translate('icon_picker_description')}
isOpen={isOpen}
onClose={handleClose}
onScroll={handleScroll}
footer={footer}
headerIcon={{
name: 'arrows-rotate',
backgroundColor: '#D6E6FF'
}}
>
<div className={style.iconPicker}>
{
<>
<div className={style.searchWrapper}>
<SearchForm
search={{
placeholder: translate('search_place_holder'),
value: searchValue,
onChange: handleSearch
}}
onReset={handleReset}
/>
</div>
{searchValue && searchResults.length === 0 ? (
<div className={style.emptySearchResultContainer}>
<div className={style.emptySearchResultTitle}>
{translate('empty_search_result_title', {searchValue})}
</div>
<div className={style.emptySearchResultDescription}>
{translate('empty_search_result_description')}
</div>
<div className={style.emptySearchResultClearSearch} onClick={handleReset}>
{translate('empty_search_result_clear_search')}
</div>
</div>
) : (
<div className={style.iconsListWrapper}>{icons}</div>
)}
</>
}
</div>
</BaseModal>
);
};

IconPickerModal.contextTypes = {
translate: Provider.childContextTypes.translate
};

IconPickerModal.propTypes = {
isOpen: PropTypes.bool,
onCancel: PropTypes.func,
onConfirm: PropTypes.func,
onClose: PropTypes.func
};

export default IconPickerModal;
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
@value colors: "../../variables/colors.css";
@value cm_grey_100 from colors;
@value cm_primary_blue from colors;
@value cm_grey_500 from colors;

.iconPicker {
height: 485px;
width: calc(71vw - 68px);
max-width: 612px;
}

.searchWrapper {
border-radius: 12px;
width: 300px;
background-color: cm_grey_100;
margin-bottom: 20px;
}

.iconsListWrapper {
display: flex;
flex-wrap: wrap;
justify-content: space-around;
margin: auto;
gap: 12px;
overflow: visible;
}

.emptySearchResultContainer {
display: flex;
flex-direction: column;
align-items: center;
}

.emptySearchResultTitle {
font-size: 20px;
font-weight: 700;
line-height: 28px;
margin-bottom: 8px;
}

.emptySearchResultDescription {
font-size: 16px;
font-weight: 500;
line-height: 22px;
margin-bottom: 16px;
}

.emptySearchResultClearSearch {
color: #0061FF;
font-size: 14px;
font-style: normal;
font-weight: 600;
line-height: 20px;
cursor: pointer;
}

@media mobile {
.iconPicker {
width: 100%;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default {
props: {
isOpen: true,
onCancel: () => {},
onConfirm: () => {},
onClose: () => {}
}
};

0 comments on commit 17309cf

Please sign in to comment.