feat: create tab and selector components

This commit is contained in:
Sonny
2024-06-02 01:22:53 +02:00
committed by Sonny
parent e9ccefd938
commit 8e1e3bea17
14 changed files with 335 additions and 43 deletions

View File

@@ -0,0 +1,79 @@
import { useTheme } from '@emotion/react';
import { InputHTMLAttributes, ReactNode, useEffect, useState } from 'react';
import Select, {
FormatOptionLabelMeta,
GroupBase,
OptionsOrGroups,
} from 'react-select';
import FormField from '~/components/common/form/_form_field';
type Option = { label: string | number; value: string | number };
interface SelectorProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
label: string;
name: string;
errors?: string[];
options: OptionsOrGroups<Option, GroupBase<Option>>;
value: number | string;
onChangeCallback?: (value: number | string) => void;
formatOptionLabel?: (
data: Option,
formatOptionLabelMeta: FormatOptionLabelMeta<Option>
) => ReactNode;
}
export default function Selector({
name,
label,
value,
errors = [],
options,
onChangeCallback,
formatOptionLabel,
required = false,
...props
}: SelectorProps): JSX.Element {
const theme = useTheme();
const [selectorValue, setSelectorValue] = useState<Option>();
useEffect(() => {
if (options.length === 0) return;
const option = options.find((o: any) => o.value === value);
if (option) {
setSelectorValue(option as Option);
}
}, [options, value]);
const handleChange = (selectedOption: Option) => {
setSelectorValue(selectedOption);
if (onChangeCallback) {
onChangeCallback(selectedOption.value);
}
};
return (
<FormField required={required}>
{label && (
<label htmlFor={name} title={`${name} field`}>
{label}
</label>
)}
<Select
value={selectorValue}
onChange={(newValue) => handleChange(newValue as Option)}
options={options}
isDisabled={props.disabled}
menuPlacement="auto"
formatOptionLabel={
formatOptionLabel
? (val, formatOptionLabelMeta) =>
formatOptionLabel(val, formatOptionLabelMeta)
: undefined
}
css={{ color: theme.colors.black }}
/>
</FormField>
);
}

View File

@@ -6,7 +6,6 @@ const ModalBody = styled.div({
flex: 1,
alignItems: 'center',
flexDirection: 'column',
overflow: 'auto',
});
export default ModalBody;

View File

@@ -3,7 +3,7 @@ import TransitionLayout from '~/components/layouts/_transition_layout';
const ModalContainer = styled(TransitionLayout)(({ theme }) => ({
minWidth: '500px',
background: theme.colors.secondary,
background: theme.colors.background,
padding: '1em',
borderRadius: theme.border.radius,
marginTop: '6em',

View File

@@ -0,0 +1,24 @@
import styled from '@emotion/styled';
import { rgba } from '~/lib/color';
const TabItem = styled.li<{ active?: boolean; danger?: boolean }>(
({ theme, active, danger }) => {
const activeColor = !danger ? theme.colors.primary : theme.colors.lightRed;
return {
userSelect: 'none',
cursor: 'pointer',
backgroundColor: active
? rgba(activeColor, 0.15)
: theme.colors.secondary,
padding: '10px 20px',
border: `1px solid ${active ? rgba(activeColor, 0.1) : theme.colors.secondary}`,
borderBottom: `1px solid ${active ? rgba(activeColor, 0.25) : theme.colors.secondary}`,
display: 'flex',
gap: '0.35em',
alignItems: 'center',
transition: '.075s',
};
}
);
export default TabItem;

View File

@@ -0,0 +1,10 @@
import styled from '@emotion/styled';
const TabList = styled.ul({
padding: 0,
margin: 0,
display: 'flex',
listStyle: 'none',
});
export default TabList;

View File

@@ -0,0 +1,12 @@
import styled from '@emotion/styled';
import { rgba } from '~/lib/color';
const TabPanel = styled.div(({ theme }) => ({
zIndex: 1,
position: 'relative',
border: `1px solid ${rgba(theme.colors.primary, 0.25)}`,
padding: '20px',
marginTop: '-1px',
}));
export default TabPanel;

View File

@@ -0,0 +1,42 @@
import { ReactNode, useState } from 'react';
import { IconType } from 'react-icons/lib';
import TabItem from '~/components/common/tabs/tab_item';
import TabList from '~/components/common/tabs/tab_list';
import TabPanel from '~/components/common/tabs/tab_panel';
import TransitionLayout from '~/components/layouts/_transition_layout';
export interface Tab {
title: string;
content: ReactNode;
icon?: IconType;
danger?: boolean;
}
export default function Tabs({ tabs }: { tabs: Tab[] }) {
const [activeTabIndex, setActiveTabIndex] = useState<number>(0);
const handleTabClick = (index: number) => {
setActiveTabIndex(index);
};
return (
<div css={{ width: '100%' }}>
<TabList>
{tabs.map(({ title, icon: Icon, danger }, index) => (
<TabItem
key={index}
active={index === activeTabIndex}
onClick={() => handleTabClick(index)}
danger={danger ?? false}
>
{!!Icon && <Icon size={20} />}
{title}
</TabItem>
))}
</TabList>
<TabPanel key={tabs[activeTabIndex].title}>
<TransitionLayout>{tabs[activeTabIndex].content}</TransitionLayout>
</TabPanel>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import Checkbox from '~/components/common/form/checkbox';
import Selector from '~/components/common/form/selector';
import TextBox from '~/components/common/form/textbox';
import BackToDashboard from '~/components/common/navigation/back_to_dashboard';
import FormLayout from '~/components/layouts/form_layout';
@@ -91,17 +92,18 @@ export default function FormLink({
errors={errors?.description}
disabled={disableInputs}
/>
<select
onChange={({ target }) => setData('collectionId', target.value)}
defaultValue={data.collectionId}
disabled={disableInputs}
>
{collections?.map((collection) => (
<option key={collection.id} value={collection.id}>
{collection.name}
</option>
))}
</select>
<Selector
label={t('collection.collections')}
name="collection"
placeholder={t('collection.collections')}
value={data.collectionId}
onChangeCallback={(value) => setData('collectionId', value)}
options={collections.map(({ id, name }) => ({
label: name,
value: id,
}))}
required
/>
<Checkbox
label={t('favorite')}
name="favorite"

View File

@@ -1,32 +1,53 @@
import dayjs from 'dayjs';
import { ChangeEvent } from 'react';
import { useTranslation } from 'react-i18next';
import Selector from '~/components/common/form/selector';
import { LS_LANG_KEY } from '~/constants';
import { languages } from '~/i18n';
export default function LangSelector() {
type Country = 'fr' | 'en';
export default function LangSelector({
onSelected,
}: {
onSelected?: (country: Country) => void;
}) {
const { t, i18n } = useTranslation('common');
const onToggleLanguageClick = ({
target,
}: ChangeEvent<HTMLSelectElement>) => {
dayjs.locale(target.value);
i18n.changeLanguage(target.value);
localStorage.setItem(LS_LANG_KEY, target.value);
const onToggleLanguageClick = (newLocale: string) => {
dayjs.locale(newLocale);
i18n.changeLanguage(newLocale);
localStorage.setItem(LS_LANG_KEY, newLocale);
};
return (
<select
onChange={onToggleLanguageClick}
name="lang-selector"
id="lang-selector"
defaultValue={i18n.language}
>
{languages.map((lang) => (
<option value={lang} key={lang}>
{t(`language.${lang}`)}
</option>
))}
</select>
<Selector
name="lng-select"
label={t('select-your-lang')}
value={i18n.language}
onChangeCallback={(value) => {
onToggleLanguageClick(value.toString());
if (onSelected) {
setTimeout(() => onSelected(value.toString() as Country), 150);
}
}}
options={languages.map((lang: Country) => ({
label: t(`language.${lang}`),
value: lang,
}))}
formatOptionLabel={(country) => (
<div
className="country-option"
style={{ display: 'flex', gap: '.5em', alignItems: 'center' }}
>
<img
src={`/icons/${country.value}.svg`}
alt="country-image"
height={24}
width={24}
/>
<span>{country.label}</span>
</div>
)}
/>
);
}

View File

@@ -67,9 +67,10 @@ function GlobalStyles() {
},
hr: {
color: localTheme.colors.secondary,
width: '100%',
marginBlock: '1em',
border: 0,
borderTop: `1px solid ${localTheme.colors.background}`,
},
});

114
package-lock.json generated
View File

@@ -40,6 +40,7 @@
"react-hotkeys-hook": "^4.5.0",
"react-i18next": "^14.1.2",
"react-icons": "^5.2.1",
"react-select": "^5.8.0",
"react-swipeable": "^7.0.1",
"react-toggle": "^4.1.3",
"reflect-metadata": "^0.2.2"
@@ -58,6 +59,7 @@
"@types/node": "^20.12.12",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react-select": "^5.0.1",
"@types/react-toggle": "^4.0.5",
"@typescript-eslint/eslint-plugin": "^7.10.0",
"@vitejs/plugin-react": "^4.3.0",
@@ -2680,6 +2682,28 @@
"npm": ">=6.14.13"
}
},
"node_modules/@floating-ui/core": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.2.tgz",
"integrity": "sha512-+2XpQV9LLZeanU4ZevzRnGFg2neDeKHgFLjP6YLW+tly0IvrhqT4u8enLGjLH3qeh85g19xY5rsAusfwTdn5lg==",
"dependencies": {
"@floating-ui/utils": "^0.2.0"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.6.5",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.5.tgz",
"integrity": "sha512-Nsdud2X65Dz+1RHjAIP0t8z5e2ff/IRbei6BqFrl1urT8sDVzM1HMQ+R0XcU5ceRfyO3I6ayeqIfh+6Wb8LGTw==",
"dependencies": {
"@floating-ui/core": "^1.0.0",
"@floating-ui/utils": "^0.2.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.2.tgz",
"integrity": "sha512-J4yDIIthosAsRZ5CPYP/jQvUAQtlZTTD/4suA08/FEnlxqW3sKS9iAhgsa9VYLZ6vDHn/ixJgIqRQPotoBjxIw=="
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.11.14",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
@@ -4230,14 +4254,11 @@
"integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==",
"dev": true
},
"node_modules/@types/prop-types": {
"devOptional": true
},
"node_modules/@types/prop-types": {},
"node_modules/@types/react": {
"version": "18.3.3",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz",
"integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==",
"devOptional": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -4252,6 +4273,16 @@
"@types/react": "*"
}
},
"node_modules/@types/react-select": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@types/react-select/-/react-select-5.0.1.tgz",
"integrity": "sha512-h5Im0AP0dr4AVeHtrcvQrLV+gmPa7SA0AGdxl2jOhtwiE6KgXBFSogWw8az32/nusE6AQHlCOHQWjP1S/+oMWA==",
"deprecated": "This is a stub types definition. react-select provides its own type definitions, so you do not need this installed.",
"dev": true,
"dependencies": {
"react-select": "*"
}
},
"node_modules/@types/react-toggle": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/@types/react-toggle/-/react-toggle-4.0.5.tgz",
@@ -4261,6 +4292,14 @@
"@types/react": "*"
}
},
"node_modules/@types/react-transition-group": {
"version": "4.4.10",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz",
"integrity": "sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/semver": {
"version": "7.5.8",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz",
@@ -7650,6 +7689,15 @@
"node": ">=6.0.0"
}
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/edge-error": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/edge-error/-/edge-error-4.0.1.tgz",
@@ -9639,6 +9687,11 @@
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"dev": true
},
"node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -10278,7 +10331,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"peer": true,
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
@@ -10289,7 +10341,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -10297,8 +10348,7 @@
"node_modules/prop-types/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"peer": true
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/property-information": {
"version": "6.5.0",
@@ -10639,6 +10689,26 @@
"node": ">=0.10.0"
}
},
"node_modules/react-select": {
"version": "5.8.0",
"resolved": "https://registry.npmjs.org/react-select/-/react-select-5.8.0.tgz",
"integrity": "sha512-TfjLDo58XrhP6VG5M/Mi56Us0Yt8X7xD6cDybC7yoRMUNm7BGO7qk8J0TLQOua/prb8vUOtsfnXZwfm30HGsAA==",
"dependencies": {
"@babel/runtime": "^7.12.0",
"@emotion/cache": "^11.4.0",
"@emotion/react": "^11.8.1",
"@floating-ui/dom": "^1.0.1",
"@types/react-transition-group": "^4.4.0",
"memoize-one": "^6.0.0",
"prop-types": "^15.6.0",
"react-transition-group": "^4.3.0",
"use-isomorphic-layout-effect": "^1.1.2"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-swipeable": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/react-swipeable/-/react-swipeable-7.0.1.tgz",
@@ -10660,6 +10730,21 @@
"react-dom": ">= 15.3.0 < 19"
}
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/read-package-up": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz",
@@ -12046,6 +12131,19 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-isomorphic-layout-effect": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz",
"integrity": "sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",

View File

@@ -60,6 +60,7 @@
"prettier": "^3.2.5",
"ts-node": "^10.9.2",
"typescript": "^5.4.5",
"@types/react-select": "^5.0.1",
"vite": "^5.2.11"
},
"dependencies": {
@@ -94,6 +95,7 @@
"react-hotkeys-hook": "^4.5.0",
"react-i18next": "^14.1.2",
"react-icons": "^5.2.1",
"react-select": "^5.8.0",
"react-swipeable": "^7.0.1",
"react-toggle": "^4.1.3",
"reflect-metadata": "^0.2.2"
@@ -120,4 +122,4 @@
"lint-staged": {
"*.js,*.ts,*.jsx,*.tsx": "eslint --cache --fix"
}
}
}

1
public/icons/en.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 30"><clipPath id="a"><path d="M0 0v30h60V0z"/></clipPath><clipPath id="b"><path d="M30 15h30v15zv15H0zH0V0zV0h30z"/></clipPath><g clip-path="url(#a)"><path d="M0 0v30h60V0z" fill="#012169"/><path d="M0 0l60 30m0-30L0 30" stroke="#fff" stroke-width="6"/><path d="M0 0l60 30m0-30L0 30" clip-path="url(#b)" stroke="#C8102E" stroke-width="4"/><path d="M30 0v30M0 15h60" stroke="#fff" stroke-width="10"/><path d="M30 0v30M0 15h60" stroke="#C8102E" stroke-width="6"/></g></svg>

After

Width:  |  Height:  |  Size: 527 B

1
public/icons/fr.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2"><path fill="#EC1920" d="M0 0h3v2H0z"/><path fill="#fff" d="M0 0h2v2H0z"/><path fill="#051440" d="M0 0h1v2H0z"/></svg>

After

Width:  |  Height:  |  Size: 175 B