@nuskin/foundation-ui-components 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuskin/foundation-ui-components",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "It is a foundation library written in react, that provides common components to all UI pages",
5
5
  "private": false,
6
6
  "main": "src/common/index.ts",
@@ -41,8 +41,8 @@
41
41
  "types:check": "tsc --pretty --noEmit"
42
42
  },
43
43
  "dependencies": {
44
- "@emotion/react": "^11.10.6",
45
- "@emotion/styled": "^11.10.6",
44
+ "@emotion/react": "^11.11.1",
45
+ "@emotion/styled": "^11.11.0",
46
46
  "@mui/icons-material": "^5.11.11",
47
47
  "@mui/material": "5.4.2",
48
48
  "@mui/utils": "^5.14.1",
@@ -71,7 +71,7 @@
71
71
  "@storybook/addon-mdx-gfm": "^7.0.7",
72
72
  "@storybook/jest": "^0.1.0",
73
73
  "@storybook/nextjs": "^7.0.7",
74
- "@storybook/react": "^7.0.7",
74
+ "@storybook/react": "^7.0.24",
75
75
  "@storybook/test-runner": "^0.10.0",
76
76
  "@storybook/testing-library": "^0.1.0",
77
77
  "@testing-library/jest-dom": "^5.16.5",
@@ -1,4 +1,5 @@
1
- import { useCallback, useState } from 'react';
1
+ import { useCallback, useRef, useState } from 'react';
2
+
2
3
  import { NsIcon } from '../icon';
3
4
  import {
4
5
  AuthDropdownContainer,
@@ -11,6 +12,7 @@ import {
11
12
  SignUpLink,
12
13
  } from './NsAuthDropdown.styled';
13
14
  import { NsAuthDropdownProps } from './types.d';
15
+ import { useOnClickOutside } from '../hooks/useOnClickOutside';
14
16
 
15
17
  const NsAuthDropdown = ({
16
18
  signInRedirectLink,
@@ -23,11 +25,14 @@ const NsAuthDropdown = ({
23
25
  darkModeEnabled = true,
24
26
  }: NsAuthDropdownProps) => {
25
27
  const [isOpen, setIsOpen] = useState(false);
28
+ const dropdowneRef = useRef<HTMLDivElement | null>(null);
26
29
 
27
30
  const handleToggle = () => {
28
- setIsOpen((prevIsOpen) => !prevIsOpen);
31
+ setIsOpen(!isOpen);
29
32
  };
30
33
 
34
+ useOnClickOutside(dropdowneRef, handleToggle);
35
+
31
36
  const closeDropdown = useCallback(() => {
32
37
  if (closeOnMouseLeave) {
33
38
  setIsOpen(false);
@@ -35,7 +40,7 @@ const NsAuthDropdown = ({
35
40
  }, [closeOnMouseLeave]);
36
41
 
37
42
  return (
38
- <AuthDropdownContainer>
43
+ <AuthDropdownContainer ref={dropdowneRef}>
39
44
  <AuthHead data-testid="auth-head" onClick={handleToggle} darkModeEnabled={darkModeEnabled}>
40
45
  <Button>{titleText}</Button>
41
46
  <NsIcon testId="chevron-icon" name={isOpen ? 'arrowUp' : 'arrowDown'} color="primary" />
@@ -1,10 +1,10 @@
1
1
  import React from 'react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { act } from 'react-dom/test-utils';
2
4
  import { render, screen, waitFor } from '@/test-utils/index';
3
- import { NsAutocomplete } from '@/common/index';
4
5
  import { OptionUiComponent } from './mock/MockUI';
5
6
  import { AutocompleteMockList } from './mock/MockData';
6
- import userEvent from '@testing-library/user-event';
7
- import { act } from 'react-dom/test-utils';
7
+ import NsAutocomplete from './NsAutocomplete';
8
8
 
9
9
  describe('CustomAutocomplete', () => {
10
10
  it('1. should render autocomplete component', () => {
@@ -1,3 +1,4 @@
1
+ /* eslint-disable max-len */
1
2
  import { OptionsProps } from '../types';
2
3
 
3
4
  export const AutocompleteMockList: OptionsProps[] = [
@@ -26,7 +26,6 @@ export default {
26
26
  color: { name: 'Color Variants' },
27
27
  disabled: { control: 'boolean' },
28
28
  fullWidth: { control: 'boolean' },
29
-
30
29
  iconOnly: { control: 'hide' },
31
30
  },
32
31
  args: {
@@ -21,7 +21,6 @@ const Component = () => {
21
21
  '.action-link': {
22
22
  color: '#2CD5C4',
23
23
  textDecoration: 'none',
24
- fontWeight: 600,
25
24
  },
26
25
  },
27
26
  '.body-text': {
@@ -1,12 +1,11 @@
1
1
  import Box from '@mui/material/Box';
2
2
  import { styled } from '@nuskin/foundation-theme';
3
- import { useRef, useCallback } from 'react';
3
+ import { useRef, useCallback, ReactNode } from 'react';
4
4
  import Slider from 'react-slick';
5
5
  import 'slick-carousel/slick/slick.css';
6
6
  import 'slick-carousel/slick/slick-theme.css';
7
7
  import defaultCarousalSettings from './slickSetting';
8
8
  import CarousalControl from './CarousalControl';
9
- import { NsCarousalProps } from './types';
10
9
 
11
10
  const SlickContainer = styled(Box)(() => {
12
11
  return {
@@ -17,8 +16,15 @@ const SlickContainer = styled(Box)(() => {
17
16
  },
18
17
  };
19
18
  });
19
+ interface CarousalProps<T = any> {
20
+ entries: T[];
21
+ // eslint-disable-next-line no-unused-vars
22
+ renderEntry(entry: T, index: number): ReactNode;
23
+ allEntryLink: string;
24
+ settings: any;
25
+ }
20
26
 
21
- export default function NsCarousal({ entries, renderEntry, allEntryLink, settings }: NsCarousalProps) {
27
+ export default function NsCarousal({ entries, renderEntry, allEntryLink, settings }: CarousalProps) {
22
28
  const sliderRef = useRef();
23
29
  const prev = useCallback(() => {
24
30
  if (sliderRef.current) {
@@ -3,13 +3,13 @@ import { render, screen } from '@/test-utils/index';
3
3
 
4
4
  const { getByTestId } = screen;
5
5
  describe('NsCartIcon', () => {
6
- it('renders Cart icon when item count is 0', () => {
6
+ it('1. renders Cart icon when item count is 0', () => {
7
7
  render(<NsCartIcon itemCount={0} color="primary" size="large" />);
8
8
  const cartIcon = getByTestId('cart-badge-icon');
9
9
  expect(cartIcon).toBeInTheDocument();
10
10
  });
11
11
 
12
- it('renders item count badge when item count is greater than 0', () => {
12
+ it('2. renders item count badge when item count is greater than 0', () => {
13
13
  const itemCount = 5;
14
14
  const { getByText } = render(<NsCartIcon itemCount={5} color="primary" size="large" />);
15
15
  const badgeContentElement = getByText(itemCount.toString());
@@ -3,7 +3,7 @@ import { CheckboxProps as MUICheckboxProps } from '@material-ui/core/Checkbox';
3
3
  import { NsFlagProps } from '../flag';
4
4
 
5
5
  // Define the props for the custom checkbox component
6
- export type NsCheckboxProps = Partial<Pick<MUICheckboxProps, 'label' | 'onChange'>> & {
6
+ export type NsCheckboxProps = Pick<MUICheckboxProps, 'label' | 'onChange'> & {
7
7
  name: string;
8
8
  className?: string;
9
9
  checked: boolean;
@@ -1,7 +1,9 @@
1
- import React, { useState } from 'react';
1
+ import React, { useRef, useState } from 'react';
2
+
2
3
  import { DropdownContainer, DropdownButton, DropdownList, DropdownHead } from './NsDropdown.styled';
3
4
  import { NsIcon } from '../icon';
4
5
  import { DropdownProps } from './types.d';
6
+ import { useOnClickOutside } from '../hooks/useOnClickOutside';
5
7
 
6
8
  const NsDropdown: React.FC<DropdownProps> = ({
7
9
  selectedValue,
@@ -17,12 +19,15 @@ const NsDropdown: React.FC<DropdownProps> = ({
17
19
  }) => {
18
20
  const [isOpen, setIsOpen] = useState(false);
19
21
  const [value, setValue] = useState(selectedValue || '');
22
+ const dropdowneRef = useRef<HTMLDivElement | null>(null);
20
23
 
21
24
  const handleToggle = () => {
22
25
  if (disabled) return;
23
26
  setIsOpen(!isOpen);
24
27
  };
25
28
 
29
+ useOnClickOutside(dropdowneRef, handleToggle);
30
+
26
31
  const closeDropdown = () => {
27
32
  if (closeOnMouseLeave) {
28
33
  setIsOpen(false);
@@ -36,7 +41,7 @@ const NsDropdown: React.FC<DropdownProps> = ({
36
41
  };
37
42
 
38
43
  return (
39
- <DropdownContainer disabled={disabled}>
44
+ <DropdownContainer disabled={disabled} ref={dropdowneRef}>
40
45
  <DropdownHead data-testid="dropdown-head" disabled={disabled} type={type} onClick={handleToggle}>
41
46
  <DropdownButton>{selectedLabel || placeholder}</DropdownButton>
42
47
  <NsIcon testId="chevron-icon" name={isOpen ? 'arrowUp' : 'arrowDown'} color="primary" />
@@ -0,0 +1,25 @@
1
+ import { RefObject, useEffect } from 'react';
2
+
3
+ export type Handler = () => void;
4
+
5
+ export function useOnClickOutside<T extends HTMLElement = HTMLElement>(
6
+ ref: RefObject<T>,
7
+ handler: Handler,
8
+ mouseEvent: 'click' | 'mousedown' | 'mouseup' = 'click'
9
+ ): void {
10
+ const handleEventListener = (event: MouseEvent) => {
11
+ const el = ref?.current;
12
+ if (!el || el.contains(event.target as Node)) {
13
+ return;
14
+ }
15
+ handler();
16
+ };
17
+ useEffect(() => {
18
+ if (document) {
19
+ document.addEventListener(mouseEvent, handleEventListener);
20
+ return () => {
21
+ document.removeEventListener(mouseEvent, handleEventListener);
22
+ };
23
+ }
24
+ }, []);
25
+ }
@@ -11,6 +11,7 @@ import CheckVerified from './icons/CheckVerified';
11
11
  import SuccessIcon from './icons/SuccessIcon';
12
12
  import ErrorIcon from './icons/ErrorIcon';
13
13
  import TrashIcon from './icons/TrashIcon';
14
+ import SearchIcon from './icons/SearchIcon';
14
15
  import SlashIcon from './icons/SlashIcon';
15
16
  import Home from './icons/home';
16
17
  import AlertErrorIcon from './icons/AlertErrorIcon';
@@ -19,7 +20,6 @@ import CloseIcon from './icons/CloseIcon';
19
20
  import UserIcon from './icons/UserIcon';
20
21
  import HamburgerIcon from './icons/HamburgerIcon';
21
22
  import UserOutline from './icons/UserOutline';
22
- import SearchIcon from './icons/SearchIcon';
23
23
 
24
24
  const IconVariants = {
25
25
  arrowDown: ArrowDown,
@@ -1,8 +1,8 @@
1
1
  export * from './accordion';
2
2
  export * from './alert';
3
3
  export * from './auth-dropdown';
4
- export * from './autocomplete';
5
4
  export * from './banner';
5
+
6
6
  export * from './breadcrumb';
7
7
  export * from './brand-components';
8
8
  export * from './button';
@@ -16,7 +16,6 @@ export const Default: Story = {
16
16
  args: {
17
17
  children: 'Label',
18
18
  required: false,
19
- size: 16,
20
19
  },
21
20
  };
22
21
 
@@ -24,7 +23,6 @@ export const Optional: Story = {
24
23
  args: {
25
24
  children: 'Label',
26
25
  showOptional: true,
27
- size: 16,
28
26
  },
29
27
  };
30
28
 
@@ -33,6 +31,5 @@ export const Required: Story = {
33
31
  children: 'Label',
34
32
  showOptional: true,
35
33
  required: true,
36
- size: 16,
37
34
  },
38
35
  };
@@ -27,7 +27,7 @@ const StyledLabel = styled(FormLabel)<{ fontSize?: number; style?: CSSProperties
27
27
  const NsLabel = (props: NsLabelProps) => {
28
28
  const { required, showOptional, children, size } = props;
29
29
  return (
30
- <StyledLabel {...props} fontSize={size} required={required}>
30
+ <StyledLabel fontSize={size} {...props} required={required}>
31
31
  {children}
32
32
  {!required && showOptional ? <span className="optional-label">{' (Optional)'}</span> : null}
33
33
  </StyledLabel>
@@ -0,0 +1,47 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import React from 'react';
3
+ import Progress from './NsProgress';
4
+
5
+ describe(' NsProgress Component', () => {
6
+ test('should render Progress bar', () => {
7
+ render(<Progress color="primary" value={10}></Progress>);
8
+
9
+ const pbar = screen.getByRole('progressbar');
10
+
11
+ expect(pbar).toBeInTheDocument();
12
+ });
13
+
14
+ it('matches the height of the progress bar to the width of the container', () => {
15
+ // Define the props
16
+ const value = 50;
17
+
18
+ // Render the StyledProgress component with the value prop
19
+ const { container } = render(<Progress color="primary" value={value} />);
20
+
21
+ // Get the progress bar element
22
+ const progressBar = container.querySelector('.MuiLinearProgress-bar');
23
+
24
+ // Check if the progress bar element exists
25
+ if (progressBar) {
26
+ // Retrieve the computed width of the container element
27
+ const containerWidth = container.clientWidth;
28
+
29
+ // Retrieve the computed styles of the progress bar
30
+ const progressBarStyle = getComputedStyle(progressBar);
31
+ const progressBarHeight = parseFloat(progressBarStyle.height);
32
+ const progressBarPadding =
33
+ parseFloat(progressBarStyle.paddingTop) + parseFloat(progressBarStyle.paddingBottom);
34
+ const progressBarBorder =
35
+ parseFloat(progressBarStyle.borderTopWidth) + parseFloat(progressBarStyle.borderBottomWidth);
36
+
37
+ // Calculate the expected height of the progress bar
38
+ const expectedHeight = containerWidth - progressBarPadding - progressBarBorder;
39
+
40
+ // Assert that the height of the progress bar matches the width of the container
41
+ expect(progressBarHeight).toBe(expectedHeight);
42
+ } else {
43
+ // Handle the case where the progress bar element is not found
44
+ throw new Error('Progress bar element not found');
45
+ }
46
+ });
47
+ });
@@ -0,0 +1,37 @@
1
+ import { withCenterDecorator } from '@/stories/decorators/withCenterDecorator';
2
+ import type { StoryObj, Meta } from '@storybook/react';
3
+ import NsProgress from './NsProgress';
4
+
5
+ type ComponentType = typeof NsProgress;
6
+ type Story = StoryObj<ComponentType>;
7
+ type MetaStory = Meta<ComponentType>;
8
+
9
+ export default {
10
+ component: NsProgress,
11
+ title: 'Input/NsProgress',
12
+ decorators: [withCenterDecorator],
13
+ argTypes: {
14
+ color: {
15
+ description: 'defines the color for the completed progress',
16
+ },
17
+ height: {
18
+ description: 'height of the bar',
19
+ },
20
+ value: {
21
+ description: 'value of the progress done',
22
+ },
23
+ backgroundColor: {
24
+ description: 'color of incomplete progress',
25
+ },
26
+ },
27
+ } as MetaStory;
28
+
29
+ export const ProgressBar: Story = {
30
+ args: {
31
+ color: 'primary',
32
+ backgroundColor: '#19dbdb',
33
+ value: undefined,
34
+ height: '10px',
35
+ body: <span>{'% Complete'}</span>,
36
+ },
37
+ };
@@ -0,0 +1,39 @@
1
+ import { styled, ColorUtils } from '@nuskin/foundation-theme';
2
+ import { LinearProgress } from '@mui/material';
3
+ import CheckCircleIcon from '@mui/icons-material/CheckCircle';
4
+ import { NsProgressProps } from './types';
5
+
6
+ const { color } = ColorUtils;
7
+ export const StyledCheckCircle = styled(CheckCircleIcon)`
8
+ color: ${color('neutral', '50')};
9
+ padding-top: 0.6em;
10
+ padding-right: 0.5em;
11
+ justify-self: flex-end;
12
+ `;
13
+
14
+ export const PercentDiv = styled('div')`
15
+ color: ${color('primary', '100')};
16
+ display: flex;
17
+ justify-content: flex-end;
18
+ font-family: Inter;
19
+ font-size: 12px;
20
+ font-style: normal;
21
+ font-weight: 700;
22
+ text-transform: uppercase;
23
+ `;
24
+
25
+ export const StyledLinearProgress = styled(LinearProgress)<Omit<NsProgressProps, 'value'>>(
26
+ ({ height, backgroundColor, color }) => ({
27
+ width: '334px',
28
+ height,
29
+ backgroundColor,
30
+ borderRadius: '3px',
31
+ display: 'inline-block',
32
+
33
+ '.MuiLinearProgress-bar': {
34
+ backgroundColor: color,
35
+ },
36
+ })
37
+ );
38
+
39
+ export default StyledLinearProgress;
@@ -0,0 +1,27 @@
1
+ import React from 'react';
2
+
3
+ import { NsProgressProps } from './types';
4
+ import StyledLinearProgress, { PercentDiv, StyledCheckCircle } from './NsProgress.styled';
5
+
6
+ const NsProgress = (props: NsProgressProps) => {
7
+ const { color, backgroundColor, height, value, body } = props;
8
+ return (
9
+ <>
10
+ <PercentDiv>
11
+ {value}
12
+ {body}
13
+ </PercentDiv>
14
+ <StyledLinearProgress
15
+ variant="determinate"
16
+ role="progressbar"
17
+ color={color}
18
+ value={value}
19
+ height={height}
20
+ backgroundColor={backgroundColor}
21
+ />
22
+ <StyledCheckCircle />
23
+ </>
24
+ );
25
+ };
26
+
27
+ export default NsProgress;
@@ -0,0 +1,2 @@
1
+ export { default as NsProgress } from './NsProgress';
2
+ export type { NsProgressProps } from './types.d';
@@ -0,0 +1,21 @@
1
+ import { FoundationTheme } from '@nuskin/foundation-theme';
2
+ import { ReactNode } from 'react';
3
+
4
+ declare module '@mui/material/LinearProgress' {
5
+ // eslint-disable-next-line no-unused-vars
6
+ interface LinearProgressPropsColorOverrides {
7
+ neutral: true;
8
+ accentColorA: true;
9
+ accentColorB: true;
10
+ accentColorC: true;
11
+ accentColorD: true;
12
+ }
13
+ }
14
+
15
+ export type NsProgressProps = {
16
+ color?: FoundationTheme.PaletteColors;
17
+ backgroundColor?: string;
18
+ height?: string;
19
+ value: number;
20
+ body?: ReactNode;
21
+ };
@@ -5,9 +5,10 @@ import { skipDarkModeEnabled } from '../util';
5
5
  const { paletteColor, isDarkMode, color } = ColorUtils;
6
6
 
7
7
  export const StyledHelperText = styled('div')`
8
- margin-top :10px;
9
- justify-self:flex-start;
10
- padding-left: 0.5em;
8
+ margin-top :10px;
9
+ padding-left: 0.5em;
10
+ text-align: left;
11
+ justify-self: flex-start;
11
12
  )`;
12
13
 
13
14
  export const StyledSpan = styled('span')`
@@ -8,7 +8,7 @@ function createSelectOptions(options: { value: string; label: string }[]) {
8
8
  }
9
9
 
10
10
  describe('NsSelectOption', () => {
11
- test('renders NsSelectOption component without errors', () => {
11
+ it('1. renders NsSelectOption component without errors', () => {
12
12
  const value = 'option1';
13
13
  const label = 'Option 1';
14
14
  const { getByTestId, getByText } = render(<NsSelectOption value={value} label={label} />);
@@ -21,7 +21,7 @@ describe('NsSelectOption', () => {
21
21
  const optionLabel = getByText(label);
22
22
  expect(optionLabel).toBeInTheDocument();
23
23
  });
24
- test('clicking on NsSelectOption selects the option', () => {
24
+ it('2. clicking on NsSelectOption selects the option', () => {
25
25
  const value = 'option1';
26
26
  const label = 'Option 1';
27
27
  const onSelect = jest.fn();
@@ -43,7 +43,7 @@ describe('NsSelect', () => {
43
43
  { value: 'Option3', label: 'Option 3' },
44
44
  ];
45
45
 
46
- it('renders the placeholder text when no value is selected', () => {
46
+ it('1. renders the placeholder text when no value is selected', () => {
47
47
  render(
48
48
  <NsSelect
49
49
  placeholder="Select an option"
@@ -58,9 +58,7 @@ describe('NsSelect', () => {
58
58
  expect(screen.getByText('Select an option')).toBeInTheDocument();
59
59
  });
60
60
 
61
- test('clicking outside the select container closes the dropdown', () => {
62
- // const value = 'option1';
63
- // const label = 'Option 1';
61
+ it('2. clicking outside the select container closes the dropdown', () => {
64
62
  const onSelect = jest.fn();
65
63
  const selectedLabel = 'Option 1';
66
64
  const { getByTestId, queryByTestId } = render(
@@ -74,7 +72,7 @@ describe('NsSelect', () => {
74
72
  expect(queryByTestId('option-Option1')).toBeNull();
75
73
  });
76
74
 
77
- it('renders the selected value label', () => {
75
+ it('3. renders the selected value label', () => {
78
76
  const selectedLabel = 'Option 1';
79
77
  render(
80
78
  <NsSelect selectedValue="Option1" selectedLabel={selectedLabel} onSelect={onSelect}>
@@ -85,7 +83,7 @@ describe('NsSelect', () => {
85
83
  expect(screen.getByText(selectedLabel)).toBeInTheDocument();
86
84
  });
87
85
 
88
- test('clicking on select button opens the dropdown', () => {
86
+ it('4. clicking on select button opens the dropdown', () => {
89
87
  const { getByTestId, getByText } = render(
90
88
  <NsSelect selectedValue="Option1" selectedLabel={selectedLabel} onSelect={onSelect}>
91
89
  {createSelectOptions(options)}
@@ -105,7 +103,7 @@ describe('NsSelect', () => {
105
103
  expect(option2).toBeInTheDocument();
106
104
  });
107
105
 
108
- test('disabling the select component prevents interaction', () => {
106
+ it('5. disabling the select component prevents interaction', () => {
109
107
  const onSelect = jest.fn();
110
108
  const { getByTestId, queryByTestId } = render(
111
109
  <NsSelect selectedValue="Option1" selectedLabel={selectedLabel} onSelect={onSelect} disabled>
@@ -124,7 +122,7 @@ describe('NsSelect', () => {
124
122
  expect(onSelect).not.toHaveBeenCalled();
125
123
  });
126
124
 
127
- it('select closes on mouse leave when closeOnMouseLeave is set to true', async () => {
125
+ it('6. select closes on mouse leave when closeOnMouseLeave is set to true', async () => {
128
126
  render(
129
127
  <NsSelect closeOnMouseLeave selectedValue={selectedValue} selectedLabel={selectedLabel} onSelect={onSelect}>
130
128
  {createSelectOptions(options)}
@@ -146,7 +144,7 @@ describe('NsSelect', () => {
146
144
  expect(option3).not.toBeInTheDocument();
147
145
  });
148
146
  });
149
- it('select does not open when disabled', () => {
147
+ it('7. select does not open when disabled', () => {
150
148
  render(
151
149
  <NsSelect selectedValue={selectedValue} selectedLabel={selectedLabel} onSelect={onSelect} disabled>
152
150
  {createSelectOptions(options)}
@@ -159,7 +157,7 @@ describe('NsSelect', () => {
159
157
  expect(screen.queryByTestId('select-list')).not.toBeInTheDocument();
160
158
  });
161
159
 
162
- it('opens the select on "ArrowDown" key press', () => {
160
+ it('8. opens the select on "ArrowDown" key press', () => {
163
161
  render(
164
162
  <NsSelect selectedValue={selectedValue} selectedLabel={selectedLabel} onSelect={onSelect}>
165
163
  {createSelectOptions(options)}
@@ -172,9 +170,77 @@ describe('NsSelect', () => {
172
170
  expect(selectHead).toBeInTheDocument();
173
171
  });
174
172
 
175
- test('renders SelectContainer with error styles when errorMessage is present', () => {
176
- const errorMessage = 'Invalid value';
173
+ it('9. closes the select when closeSelect is called', () => {
174
+ const { getByTestId } = render(
175
+ <NsSelect selectedValue={selectedValue} selectedLabel={selectedLabel} onSelect={onSelect} closeOnMouseLeave>
176
+ {createSelectOptions(options)}
177
+ </NsSelect>
178
+ );
179
+
180
+ const selectContainer = getByTestId('select-container');
181
+ fireEvent.mouseLeave(selectContainer);
182
+
183
+ expect(screen.queryByTestId('select-list')).not.toBeInTheDocument();
184
+ });
185
+
186
+ it('10. does not open the select when disabled', () => {
177
187
  render(
188
+ <NsSelect selectedValue={selectedValue} selectedLabel={selectedLabel} onSelect={onSelect} disabled>
189
+ {createSelectOptions(options)}
190
+ </NsSelect>
191
+ );
192
+
193
+ const selectHead = screen.getByTestId('select-head');
194
+ fireEvent.mouseDown(selectHead);
195
+
196
+ expect(screen.queryByTestId('select-list')).not.toBeInTheDocument();
197
+ });
198
+
199
+ it('11. selects the next option on arrow right click', () => {
200
+ // Render the NsSelect component with options and onSelect function
201
+ const onSelect = jest.fn();
202
+ const { getByTestId } = render(
203
+ <NsSelect selectedValue={selectedValue} selectedLabel={selectedLabel} onSelect={onSelect} hasArrows>
204
+ {createSelectOptions(options)}
205
+ </NsSelect>
206
+ );
207
+
208
+ // Simulate the arrow right click on the NsSelect component
209
+ const selectHead = getByTestId('select-head');
210
+ fireEvent.mouseDown(selectHead);
211
+
212
+ const rightArrow = document.getElementsByClassName('RightArrow')[0];
213
+ fireEvent.click(rightArrow);
214
+
215
+ // Verify that the next option is selected and the onSelect function is called
216
+ expect(onSelect).toHaveBeenCalledWith('Option1');
217
+ });
218
+
219
+ it('12. selects the previous option on arrow left click', () => {
220
+ const { getByTestId } = render(
221
+ <NsSelect
222
+ selectedValue={selectedValue}
223
+ selectedLabel={selectedLabel}
224
+ onSelect={onSelect}
225
+ hasArrows
226
+ reverseArrowControls
227
+ >
228
+ {createSelectOptions(options)}
229
+ </NsSelect>
230
+ );
231
+
232
+ const selectHead = getByTestId('select-head');
233
+ fireEvent.mouseDown(selectHead);
234
+
235
+ const leftArrow = document.getElementsByClassName('LeftArrow')[0];
236
+ fireEvent.click(leftArrow);
237
+
238
+ expect(onSelect).toHaveBeenCalledWith('Option1');
239
+ });
240
+
241
+ it('13. renders an error message with red styles', () => {
242
+ const errorMessage = 'Invalid value';
243
+ const { getByText } = render(
178
244
  <NsSelect
179
245
  selectedValue={selectedValue}
180
246
  selectedLabel={selectedLabel}
@@ -185,9 +251,24 @@ describe('NsSelect', () => {
185
251
  </NsSelect>
186
252
  );
187
253
 
188
- const selectContainer = screen.getByTestId('select-container');
254
+ // Check if the error message is rendered correctly with red styles
255
+ const errorText = getByText(errorMessage);
256
+ expect(errorText).toBeInTheDocument();
257
+ });
258
+ it('14. selecting an option calls the onSelect callback', () => {
259
+ const onSelect = jest.fn();
260
+ const { getByTestId } = render(
261
+ <NsSelect selectedValue={selectedValue} selectedLabel={selectedLabel} onSelect={onSelect}>
262
+ {createSelectOptions(options)}
263
+ </NsSelect>
264
+ );
265
+
266
+ const selectHead = getByTestId('select-head');
267
+ fireEvent.mouseDown(selectHead);
268
+
269
+ const option1 = getByTestId('option-Option1');
270
+ fireEvent.click(option1);
189
271
 
190
- expect(selectContainer).toHaveStyle('borderColor: red');
191
- expect(selectContainer).toHaveStyle('color: red');
272
+ expect(onSelect).toHaveBeenCalledWith('Option1');
192
273
  });
193
274
  });
@@ -1,12 +1,27 @@
1
+ import { HTMLAttributes } from 'react';
1
2
  import { styled, ColorUtils, FoundationTheme } from '@nuskin/foundation-theme';
2
3
  import { Themed } from '../common.types';
3
4
  import { getDropdownHeadBorder } from '../dropdown/NsDropdown.styled';
4
5
  const { color, paletteColor, isDarkMode } = ColorUtils;
5
6
 
6
- export const SelectContainer = styled.div<{ disabled: boolean }>`
7
+ interface SelectLabelProps extends HTMLAttributes<HTMLLabelElement> {
8
+ errorMessage?: string;
9
+ }
10
+
11
+ export const SelectContainer = styled.div<{ disabled: boolean; errorMessage?: string }>`
7
12
  position: relative;
8
13
  display: inline-block;
9
14
  opacity: ${({ disabled }) => (disabled ? '0.5' : '1')};
15
+ color: ${({ theme, errorMessage }) =>
16
+ errorMessage ? paletteColor('error')({ theme }) : paletteColor('primary')({ theme })};
17
+ `;
18
+ export const SelectMain = styled.div`
19
+ position: relative;
20
+ display: flex;
21
+ `;
22
+
23
+ export const ErrorContainer = styled.div`
24
+ color: ${paletteColor('error')};
10
25
  `;
11
26
 
12
27
  export const SelectHead = styled.div<{ type: string; disabled: boolean }>`
@@ -26,6 +41,18 @@ export const SelectButton = styled.div`
26
41
  padding-right: 10px;
27
42
  `;
28
43
 
44
+ export const LeftArrowContainer = styled.div`
45
+ display: flex;
46
+ align-items: center;
47
+ padding-top: 30px;
48
+ `;
49
+
50
+ export const RightArrowContainer = styled.div`
51
+ display: flex;
52
+ align-items: center;
53
+ padding-top: 30px;
54
+ `;
55
+
29
56
  export const SelectList = styled.div<{ borderColor: FoundationTheme.PaletteColors }>`
30
57
  position: absolute;
31
58
  z-index: 100;
@@ -33,18 +60,28 @@ export const SelectList = styled.div<{ borderColor: FoundationTheme.PaletteColor
33
60
  padding: 0;
34
61
  margin: 0;
35
62
  width: -webkit-fill-available;
36
- color: ${paletteColor('primary')};
63
+ color: ${paletteColor('primary', 'main')};
37
64
  background-color: ${paletteColor('primary', 'contrastText')};
65
+ max-height: 200px;
66
+ overflow-y: auto;
38
67
  `;
39
68
 
40
- const getDividerColor = (props: { theme: FoundationTheme.Theme }) => {
41
- return !isDarkMode(props) ? color('neutral', '30')(props) : color('neutral', '20')(props);
42
- };
43
-
44
- const getListBgColor = (props: { theme: FoundationTheme.Theme }) => {
69
+ const getDividerColor = ({ theme }: Themed) =>
70
+ isDarkMode({ theme }) ? color('neutral', '20')({ theme }) : color('neutral', '30')({ theme });
71
+ const getListBgColor = (props: Themed) => {
45
72
  return !isDarkMode(props) ? color('accentColorB', '10')(props) : color('accentColorB', '50')(props);
46
73
  };
47
74
  type ContainerParam = { borderBottom: string; bgColor: string; color: string; hoveredBg: string };
75
+
76
+ export const ListItemContainer = styled.div<{ showListDivider: boolean; isSelected: boolean }>`
77
+ ${({ showListDivider, isSelected, theme }) => {
78
+ const borderBottom = showListDivider ? getDividerColor({ theme }) : 'none';
79
+ const bgColor = isSelected ? paletteColor('primary')({ theme }) : 'white';
80
+ const color = isSelected ? 'white' : 'black';
81
+ const hoveredBg = getListBgColor({ theme });
82
+ return createListContainerStyle({ borderBottom, bgColor, color, hoveredBg });
83
+ }}
84
+ `;
48
85
  function createListContainerStyle({ borderBottom, bgColor, color, hoveredBg }: ContainerParam) {
49
86
  return `
50
87
  cursor: pointer;
@@ -62,15 +99,7 @@ function createListContainerStyle({ borderBottom, bgColor, color, hoveredBg }: C
62
99
  }
63
100
  `;
64
101
  }
65
- export const ListItemContainer = styled.div<{ showListDivider: boolean; isSelected: boolean }>`
66
- ${({ showListDivider, isSelected, theme }) => {
67
- const borderBottom = showListDivider ? getDividerColor({ theme }) : 'none';
68
- const bgColor = isSelected ? paletteColor('primary')({ theme }) : 'white';
69
- const color = isSelected ? 'white' : 'black';
70
- const hoveredBg = getListBgColor({ theme });
71
- return createListContainerStyle({ borderBottom, bgColor, color, hoveredBg });
72
- }}
73
- `;
102
+
74
103
  const getListItemBgColor = ({ isSelected, theme }: { isSelected: boolean } & Themed) => {
75
104
  if (isSelected) {
76
105
  return paletteColor('primary')({ theme });
@@ -86,6 +115,14 @@ const getListItemBorderLeftColor = ({ isSelected, theme }: { isSelected: boolean
86
115
  return 'none';
87
116
  };
88
117
 
118
+ export const SelectLabel = styled.label<SelectLabelProps>`
119
+ display: block;
120
+ margin-bottom: 5px;
121
+ color: ${({ theme, errorMessage }) =>
122
+ errorMessage ? paletteColor('error')({ theme }) : paletteColor('primary')({ theme })};
123
+ }
124
+ `;
125
+
89
126
  export const ListItem = styled.div<{ isSelected: boolean }>(({ isSelected, theme }) => ({
90
127
  padding: '5px 10px',
91
128
  borderLeft: getListItemBorderLeftColor({ isSelected, theme }),
@@ -96,9 +133,3 @@ export const ListItem = styled.div<{ isSelected: boolean }>(({ isSelected, theme
96
133
  fontWeight: 'bold',
97
134
  },
98
135
  }));
99
-
100
- export const SelectLabel = styled.label`
101
- display: block;
102
- margin-bottom: 5px;
103
- color: ${paletteColor('primary')} !important;
104
- `;
@@ -1,8 +1,17 @@
1
- import React, { useState, useRef, useEffect } from 'react';
2
- import { SelectContainer, SelectButton, SelectList, SelectHead, SelectLabel } from './NsSelect.styled';
1
+ import React, { useState, useRef, useEffect, Children } from 'react';
2
+ import {
3
+ SelectContainer,
4
+ SelectButton,
5
+ SelectList,
6
+ SelectHead,
7
+ SelectLabel,
8
+ SelectMain,
9
+ LeftArrowContainer,
10
+ RightArrowContainer,
11
+ ErrorContainer,
12
+ } from './NsSelect.styled';
3
13
  import { NsIcon } from '../icon';
4
14
  import { SelectProps } from './types.d';
5
-
6
15
  const NsSelect: React.FC<SelectProps> = ({
7
16
  selectedValue,
8
17
  selectedLabel,
@@ -17,9 +26,14 @@ const NsSelect: React.FC<SelectProps> = ({
17
26
  label,
18
27
  readOnly = false,
19
28
  errorMessage,
29
+ hasArrows,
30
+ // eslint-disable-next-line no-unused-vars
31
+ rules,
32
+ reverseArrowControls,
20
33
  }) => {
21
34
  const [isOpen, setIsOpen] = useState(false);
22
35
  const [value, setValue] = useState(selectedValue || '');
36
+ const [selectedIndex, setSelectedIndex] = useState(-1);
23
37
  const selectContainerRef = useRef<HTMLDivElement>(null);
24
38
  const selectHeadRef = useRef<HTMLDivElement>(null);
25
39
 
@@ -29,19 +43,16 @@ const NsSelect: React.FC<SelectProps> = ({
29
43
  }
30
44
  };
31
45
 
32
- const handleSelect = (input: string) => {
46
+ const handleSelect = (input: string, index: number) => {
33
47
  onSelect(input);
34
48
  setIsOpen(false);
35
49
  setValue(input);
50
+ setSelectedIndex(index);
36
51
  };
37
52
 
38
53
  // eslint-disable-next-line react-hooks/exhaustive-deps
39
54
  const handleClickOutside = (event: MouseEvent) => {
40
- if (
41
- selectContainerRef.current &&
42
- !selectContainerRef.current.contains(event.target as Node) &&
43
- isOpen
44
- ) {
55
+ if (selectContainerRef.current && !selectContainerRef.current.contains(event.target as Node) && isOpen) {
45
56
  setIsOpen(false);
46
57
  }
47
58
  };
@@ -55,6 +66,40 @@ const NsSelect: React.FC<SelectProps> = ({
55
66
  }, 0);
56
67
  }
57
68
  };
69
+ const handleArrowRightClick = () => {
70
+ if (selectedIndex !== -1) {
71
+ const nextIndex = reverseArrowControls ? selectedIndex - 1 : selectedIndex + 1;
72
+ const childrenArray = Children.toArray(children);
73
+ const optionAtIndex = childrenArray[nextIndex];
74
+ const { value } = (optionAtIndex as any)?.props ?? {};
75
+ handleSelect(value, nextIndex);
76
+ } else {
77
+ if (!reverseArrowControls) {
78
+ const nextIndex = selectedIndex + 1;
79
+ const childrenArray = Children.toArray(children);
80
+ const optionAtIndex = childrenArray[nextIndex];
81
+ const { value } = (optionAtIndex as any)?.props ?? {};
82
+ handleSelect(value, nextIndex);
83
+ }
84
+ }
85
+ };
86
+ const handleArrowLeftClick = () => {
87
+ if (selectedIndex !== -1) {
88
+ const nextIndex = reverseArrowControls ? selectedIndex + 1 : selectedIndex - 1;
89
+ const childrenArray = Children.toArray(children);
90
+ const optionAtIndex = childrenArray[nextIndex];
91
+ const { value } = (optionAtIndex as any)?.props ?? {};
92
+ handleSelect(value, nextIndex);
93
+ } else {
94
+ if (reverseArrowControls) {
95
+ const nextIndex = selectedIndex + 1;
96
+ const childrenArray = Children.toArray(children);
97
+ const optionAtIndex = childrenArray[nextIndex];
98
+ const { value } = (optionAtIndex as any)?.props ?? {};
99
+ handleSelect(value, nextIndex);
100
+ }
101
+ }
102
+ };
58
103
 
59
104
  useEffect(() => {
60
105
  document.addEventListener('click', handleClickOutside);
@@ -64,53 +109,73 @@ const NsSelect: React.FC<SelectProps> = ({
64
109
  }, [handleClickOutside, isOpen]);
65
110
 
66
111
  return (
67
- <SelectContainer
68
- data-testid="select-container"
69
- disabled={disabled || readOnly}
70
- ref={selectContainerRef}
71
- style={{
72
- borderColor: errorMessage ? 'red' : undefined,
73
- color: errorMessage ? 'red' : 'inherit',
74
- }}
75
- >
76
- {label && <SelectLabel style={{ color: errorMessage ? 'red' : 'inherit' }}>{label}</SelectLabel>}
77
- <SelectHead
78
- data-testid="select-head"
112
+ <SelectMain>
113
+ {hasArrows && (
114
+ <LeftArrowContainer
115
+ onClick={handleArrowLeftClick}
116
+ className="LeftArrow"
117
+ style={{
118
+ visibility:
119
+ !reverseArrowControls && (selectedIndex === 0 || selectedIndex < 0) ? 'hidden' : 'visible',
120
+ }}
121
+ >
122
+ <NsIcon name="arrowLeft" color="primary" />
123
+ </LeftArrowContainer>
124
+ )}
125
+ <SelectContainer
126
+ data-testid="select-container"
79
127
  disabled={disabled || readOnly}
80
- type={type}
81
- onMouseDown={handleMouseClick}
82
- tabIndex={0}
83
- ref={selectHeadRef}
128
+ ref={selectContainerRef}
129
+ errorMessage={errorMessage}
84
130
  >
85
- <SelectButton>{selectedLabel || placeholder}</SelectButton>
86
- {!readOnly && (
87
- <NsIcon testId="chevron-icon" name={isOpen ? 'arrowUp' : 'arrowDown'} color="primary" />
88
- )}
89
- </SelectHead>
90
- {isOpen ? (
91
- <SelectList
92
- data-testid="select-list"
93
- onMouseLeave={closeSelect}
94
- borderColor={selectBorderColor}
95
- className='selectList'
131
+ {label && <SelectLabel errorMessage={errorMessage}>{label}</SelectLabel>}
132
+ <SelectHead
133
+ data-testid="select-head"
134
+ disabled={disabled || readOnly}
135
+ type={type}
136
+ onMouseDown={handleMouseClick}
137
+ tabIndex={0}
138
+ ref={selectHeadRef}
96
139
  >
97
- <div style={{ maxHeight: '200px', overflowY: 'auto' }}>
98
- {React.Children.map(children, (child) => {
140
+ <SelectButton>{selectedLabel || placeholder}</SelectButton>
141
+ {!readOnly && <NsIcon name={isOpen ? 'arrowUp' : 'arrowDown'} color="primary" />}
142
+ </SelectHead>
143
+ {isOpen ? (
144
+ <SelectList
145
+ data-testid="select-list"
146
+ onMouseLeave={closeSelect}
147
+ borderColor={selectBorderColor}
148
+ className="selectList"
149
+ >
150
+ {React.Children.map(children, (child, index) => {
99
151
  if (React.isValidElement(child)) {
100
152
  return React.cloneElement(child, {
101
153
  // @ts-ignore
102
- onSelect: handleSelect,
154
+ onSelect: (input: string) => handleSelect(input, index),
103
155
  selectedValue: value,
104
156
  showListDivider,
157
+ index,
105
158
  });
106
159
  }
107
160
  return null;
108
161
  })}
109
- </div>
110
- </SelectList>
111
- ) : null}
112
- {errorMessage && <div style={{ color: 'red' }}>{errorMessage}</div>}
113
- </SelectContainer>
162
+ </SelectList>
163
+ ) : null}
164
+ {errorMessage && <ErrorContainer>{errorMessage}</ErrorContainer>}
165
+ </SelectContainer>
166
+ {hasArrows && (
167
+ <RightArrowContainer
168
+ onClick={handleArrowRightClick}
169
+ className="RightArrow"
170
+ style={{
171
+ visibility:
172
+ reverseArrowControls && (selectedIndex === 0 || selectedIndex < 0) ? 'hidden' : 'visible',
173
+ }}
174
+ >
175
+ <NsIcon name="arrowRight" color="primary" />
176
+ </RightArrowContainer>
177
+ )}
178
+ </SelectMain>
114
179
  );
115
180
  };
116
181
 
@@ -24,7 +24,6 @@ export const NsSelectOption: React.FC<SelectOptionProps> = ({
24
24
  >
25
25
  <ListItem isSelected={value === selectedValue}>{label}</ListItem>
26
26
  </ListItemContainer>
27
-
28
27
  );
29
28
  };
30
29
  export default NsSelectOption;
@@ -1,13 +1,16 @@
1
1
  import React, { useState } from 'react';
2
- import { ComponentStory, ComponentMeta } from '@storybook/react';
2
+ import type { Meta, StoryObj } from '@storybook/react';
3
3
  import NsSelect from '../NsSelect';
4
4
  import NsSelectOption from '../NsSelectOption';
5
+ import { withCenterDecorator } from '@/stories/decorators/withCenterDecorator';
6
+
7
+ type ComponentType = typeof NsSelect;
8
+ type Story = StoryObj<ComponentType>;
9
+ type MetaStory = Meta<ComponentType>;
5
10
 
6
11
  export default {
7
12
  title: 'Components/Inputs/NsSelect',
8
- decorators: [
9
- (story) => <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>{story()}</div>,
10
- ],
13
+ decorators: [withCenterDecorator],
11
14
  component: NsSelect,
12
15
  argTypes: {
13
16
  type: { control: 'select', options: ['unfilled', 'outlined'] },
@@ -26,6 +29,7 @@ export default {
26
29
  validateOnBlur: { control: 'boolean' },
27
30
  autoComplete: { control: 'text' },
28
31
  rules: { control: 'array' },
32
+ hasArrows: { control: 'boolean' },
29
33
  },
30
34
  parameters: {
31
35
  options: [
@@ -100,17 +104,16 @@ export default {
100
104
  validateOnBlur: false,
101
105
  autoComplete: '',
102
106
  rules: [],
107
+ hasArrows: false,
103
108
  },
104
- } as ComponentMeta<typeof NsSelect>;
105
-
106
- export const Select: ComponentStory<typeof NsSelect> = (args, context) => {
107
- const [selectedLabel, setSelectedLabel] = useState(args.selectedLabel);
108
- const [selectedValue, setSelectedValue] = useState(args.selectedValue);
109
+ } as MetaStory;
109
110
 
110
- const options = context.parameters.options;
111
+ const AppSelect = (props: any) => {
112
+ const [selectedLabel, setSelectedLabel] = useState(props.selectedLabel);
113
+ const [selectedValue, setSelectedValue] = useState(props.selectedValue);
111
114
  const handleSelect = (value: string) => {
112
115
  setSelectedValue(value);
113
- options.find((option: any) => {
116
+ props.options.find((option: any) => {
114
117
  if (option.value === value) {
115
118
  setSelectedLabel(option.label);
116
119
  }
@@ -120,15 +123,21 @@ export const Select: ComponentStory<typeof NsSelect> = (args, context) => {
120
123
 
121
124
  return (
122
125
  <NsSelect
123
- {...args}
126
+ {...props}
124
127
  selectedValue={selectedValue}
125
128
  selectedLabel={selectedLabel}
126
129
  onSelect={handleSelect}
127
- label={args.label}
130
+ label={props.label}
128
131
  >
129
- {options.map((option: any) => (
132
+ {props.options.map((option: any) => (
130
133
  <NsSelectOption key={option.value} value={option.value} label={option.label} />
131
134
  ))}
132
135
  </NsSelect>
133
136
  );
134
137
  };
138
+ export const Select: Story = {
139
+ render: (args, context) => {
140
+ const options = context.parameters.options;
141
+ return <AppSelect options={options} {...args} />;
142
+ },
143
+ };
@@ -22,7 +22,9 @@ export interface SelectProps {
22
22
  itemValue?: string;
23
23
  validateOnBlur?: boolean;
24
24
  autoComplete?: string;
25
- rules?: any[];
25
+ // eslint-disable-next-line no-undef
26
+ rules?: Array;
27
+ hasArrows?: boolean;
26
28
  }
27
29
 
28
30
  export interface SelectOptionProps {