@hyphen/hyphen-components 2.12.4 → 2.13.1

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/dist/index.d.ts CHANGED
@@ -30,6 +30,7 @@ export * from './components/Modal/Modal';
30
30
  export * from './components/Pagination/Pagination';
31
31
  export * from './components/Popover/Popover';
32
32
  export * from './components/RadioGroup/RadioGroup';
33
+ export * from './components/RangeInput/RangeInput';
33
34
  export * from './components/ResponsiveProvider/ResponsiveProvider';
34
35
  export * from './components/SelectInput/SelectInput';
35
36
  export * from './components/SelectInputInset/SelectInputInset';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyphen/hyphen-components",
3
- "version": "2.12.4",
3
+ "version": "2.13.1",
4
4
  "license": "MIT",
5
5
  "author": {
6
6
  "name": "@hyphen"
@@ -169,8 +169,11 @@ describe('Pagination', () => {
169
169
  expect(ellipsisFound.length).toBe(2);
170
170
 
171
171
  const buttonsFound = screen.queryAllByRole('button');
172
- expect(buttonsFound[2]).toHaveTextContent('...');
173
- expect(buttonsFound[buttonsFound.length - 3]).toHaveTextContent('...');
172
+ expect(buttonsFound.length).toBe(8);
173
+ expect(ellipsisFound[0].previousElementSibling?.textContent).toBe('1');
174
+ expect(ellipsisFound[0].nextElementSibling?.textContent).toBe('4');
175
+ expect(ellipsisFound[1].previousElementSibling?.textContent).toBe('7');
176
+ expect(ellipsisFound[1].nextElementSibling?.textContent).toBe('12');
174
177
  });
175
178
  });
176
179
 
@@ -4,7 +4,6 @@ import { Box } from '../Box/Box';
4
4
  import { Button } from '../Button/Button';
5
5
  import {
6
6
  generatePages,
7
- generatePageRange,
8
7
  generatePageTotal,
9
8
  generateActiveListRange,
10
9
  } from './Pagination.utilities';
@@ -72,34 +71,43 @@ export const Pagination: FC<PaginationProps> = ({
72
71
  numberOfPagesDisplayed = 5,
73
72
  prevPageText = 'Previous',
74
73
  }) => {
75
- const pageTotal = useMemo(
76
- () => generatePageTotal(totalItemsCount, itemsPerPage),
77
- [totalItemsCount, itemsPerPage]
78
- );
74
+ const pageTotal = useMemo(() => {
75
+ if (itemsPerPage <= 0) return 1;
76
+ return generatePageTotal(totalItemsCount, itemsPerPage);
77
+ }, [totalItemsCount, itemsPerPage]);
79
78
 
80
- const pageRange = useMemo(
81
- () => generatePageRange(numberOfPagesDisplayed, pageTotal),
82
- [numberOfPagesDisplayed, pageTotal]
83
- );
79
+ const validActivePage = Math.max(1, Math.min(activePage, pageTotal));
84
80
 
85
81
  const activeListRange = useMemo(
86
- () => generateActiveListRange(activePage, totalItemsCount, itemsPerPage),
87
- [activePage, totalItemsCount, itemsPerPage]
82
+ () =>
83
+ generateActiveListRange(validActivePage, totalItemsCount, itemsPerPage),
84
+ [validActivePage, totalItemsCount, itemsPerPage]
88
85
  );
89
86
 
90
87
  const pages = useMemo(
91
- () =>
92
- generatePages(pageRange, pageTotal, activePage, numberOfPagesDisplayed),
93
- [pageRange, pageTotal, activePage, numberOfPagesDisplayed]
88
+ () => generatePages(pageTotal, validActivePage, numberOfPagesDisplayed),
89
+ [pageTotal, validActivePage, numberOfPagesDisplayed]
90
+ );
91
+
92
+ const paginationClassNames = useMemo(
93
+ () => classNames(className),
94
+ [className]
94
95
  );
95
96
 
97
+ const activeListRangeText = useMemo(() => {
98
+ if (totalItemsCount === 0) {
99
+ return 'No items to display';
100
+ }
101
+ return `Showing ${activeListRange.first}-${activeListRange.last} of ${totalItemsCount}`;
102
+ }, [activeListRange, totalItemsCount]);
103
+
96
104
  return (
97
105
  <Box
98
106
  as="nav"
99
107
  direction="row"
100
108
  alignItems="center"
101
109
  justifyContent="space-between"
102
- className={classNames(className)}
110
+ className={paginationClassNames}
103
111
  >
104
112
  <Box
105
113
  direction="row"
@@ -110,16 +118,15 @@ export const Pagination: FC<PaginationProps> = ({
110
118
  <Button
111
119
  variant="secondary"
112
120
  size={isCompact ? 'sm' : 'md'}
113
- isDisabled={activePage === 1}
114
- onClick={() => onChange(activePage - 1)}
121
+ isDisabled={validActivePage === 1}
122
+ onClick={() => onChange(validActivePage - 1)}
115
123
  >
116
124
  {prevPageText}
117
125
  </Button>
118
126
  {arePagesVisible && (
119
127
  <Box direction="row" gap="2xs">
120
- {pages.map(({ pageNumber, isPage }) => {
121
- console.log(activePage, pageNumber, isPage);
122
- return (
128
+ {pages.map(({ pageNumber, isPage }, index) => {
129
+ return isPage ? (
123
130
  <Button
124
131
  key={pageNumber}
125
132
  onClick={() => onChange(pageNumber)}
@@ -130,8 +137,20 @@ export const Pagination: FC<PaginationProps> = ({
130
137
  }}
131
138
  className={className}
132
139
  >
133
- {isPage ? pageNumber : '...'}
140
+ {pageNumber}
134
141
  </Button>
142
+ ) : (
143
+ <Box
144
+ key={`ellipsis-${index}`}
145
+ style={{
146
+ display: 'flexk',
147
+ minWidth: isCompact ? '33px' : '42px',
148
+ justifyContent: 'space-around',
149
+ alignItems: 'center',
150
+ }}
151
+ >
152
+ ...
153
+ </Box>
135
154
  );
136
155
  })}
137
156
  </Box>
@@ -139,8 +158,8 @@ export const Pagination: FC<PaginationProps> = ({
139
158
  <Button
140
159
  variant="secondary"
141
160
  size={isCompact ? 'sm' : 'md'}
142
- isDisabled={activePage === pageTotal}
143
- onClick={() => onChange(activePage + 1)}
161
+ isDisabled={validActivePage === pageTotal}
162
+ onClick={() => onChange(validActivePage + 1)}
144
163
  >
145
164
  {nextPageText}
146
165
  </Button>
@@ -153,8 +172,7 @@ export const Pagination: FC<PaginationProps> = ({
153
172
  }}
154
173
  fontSize={isCompact ? 'sm' : 'md'}
155
174
  >
156
- {isTotalVisible &&
157
- `Showing ${activeListRange.first}-${activeListRange.last} of ${totalItemsCount}`}
175
+ {isTotalVisible && activeListRangeText}
158
176
  </Box>
159
177
  </Box>
160
178
  );
@@ -1,27 +1,9 @@
1
1
  import {
2
2
  generatePages,
3
- generatePageRange,
4
3
  generatePageTotal,
5
4
  generateActiveListRange,
6
5
  } from './Pagination.utilities';
7
6
 
8
- describe('generatePageRange', () => {
9
- it('returns the number of pages displayed if there are enough total pages', () => {
10
- const pageRange = generatePageRange(3, 50);
11
- expect(pageRange).toBe(3);
12
- });
13
-
14
- it('returns the page total if it is smaller than the number of pages displayed', () => {
15
- const pageRange = generatePageRange(3, 2);
16
- expect(pageRange).toBe(2);
17
- });
18
-
19
- it('returns the number of pages displayed if it is the same as total pages', () => {
20
- const pageRange = generatePageRange(3, 3);
21
- expect(pageRange).toBe(3);
22
- });
23
- });
24
-
25
7
  describe('generatePageTotal', () => {
26
8
  it('returns correct number of pages for a variety of inputs', () => {
27
9
  const pageTotal1 = generatePageTotal(948, 20);
@@ -51,8 +33,8 @@ describe('generateActiveListRange', () => {
51
33
 
52
34
  describe('generatePages', () => {
53
35
  it('returns correct pages -- scenario 1', () => {
54
- const pages = generatePages(3, 10, 3, 3);
55
- expect(pages.length).toBe(5);
36
+ const pages = generatePages(10, 3, 3);
37
+ expect(pages.length).toBe(6);
56
38
 
57
39
  expect(pages[0].isPage).toBe(true);
58
40
  expect(pages[0].pageNumber).toBe(1);
@@ -63,22 +45,25 @@ describe('generatePages', () => {
63
45
  expect(pages[2].isPage).toBe(true);
64
46
  expect(pages[2].pageNumber).toBe(3);
65
47
 
66
- expect(pages[3].isPage).toBe(false);
67
- expect(pages[3].pageNumber).toBe(6);
48
+ expect(pages[3].isPage).toBe(true);
49
+ expect(pages[3].pageNumber).toBe(4);
68
50
 
69
- expect(pages[4].isPage).toBe(true);
70
- expect(pages[4].pageNumber).toBe(10);
51
+ expect(pages[4].isPage).toBe(false);
52
+ expect(pages[4].pageNumber).toBe(-1);
53
+
54
+ expect(pages[5].isPage).toBe(true);
55
+ expect(pages[5].pageNumber).toBe(10);
71
56
  });
72
57
 
73
58
  it('returns correct pages -- scenario 2', () => {
74
- const pages = generatePages(3, 10, 6, 3);
59
+ const pages = generatePages(10, 6, 3);
75
60
  expect(pages.length).toBe(7);
76
61
 
77
62
  expect(pages[0].isPage).toBe(true);
78
63
  expect(pages[0].pageNumber).toBe(1);
79
64
 
80
65
  expect(pages[1].isPage).toBe(false);
81
- expect(pages[1].pageNumber).toBe(3);
66
+ expect(pages[1].pageNumber).toBe(-1);
82
67
 
83
68
  expect(pages[2].isPage).toBe(true);
84
69
  expect(pages[2].pageNumber).toBe(5);
@@ -90,21 +75,21 @@ describe('generatePages', () => {
90
75
  expect(pages[4].pageNumber).toBe(7);
91
76
 
92
77
  expect(pages[5].isPage).toBe(false);
93
- expect(pages[5].pageNumber).toBe(9);
78
+ expect(pages[5].pageNumber).toBe(-1);
94
79
 
95
80
  expect(pages[6].isPage).toBe(true);
96
81
  expect(pages[6].pageNumber).toBe(10);
97
82
  });
98
83
 
99
84
  it('returns correct pages -- scenario 3', () => {
100
- const pages = generatePages(3, 10, 9, 3);
85
+ const pages = generatePages(10, 9, 3);
101
86
  expect(pages.length).toBe(5);
102
87
 
103
88
  expect(pages[0].isPage).toBe(true);
104
89
  expect(pages[0].pageNumber).toBe(1);
105
90
 
106
91
  expect(pages[1].isPage).toBe(false);
107
- expect(pages[1].pageNumber).toBe(6);
92
+ expect(pages[1].pageNumber).toBe(-1);
108
93
 
109
94
  expect(pages[2].isPage).toBe(true);
110
95
  expect(pages[2].pageNumber).toBe(8);
@@ -117,7 +102,7 @@ describe('generatePages', () => {
117
102
  });
118
103
 
119
104
  it('returns the correct pages -- one less page range than total', () => {
120
- const pages = generatePages(2, 3, 1, 2);
105
+ const pages = generatePages(3, 1, 2);
121
106
 
122
107
  expect(pages[0].isPage).toBe(true);
123
108
  expect(pages[0].pageNumber).toBe(1);
@@ -4,70 +4,45 @@ export interface Page {
4
4
  }
5
5
 
6
6
  export const generatePages = (
7
- pageRange: number,
8
7
  pageTotal: number,
9
8
  activePage: number,
10
9
  numberOfPagesDisplayed: number
11
10
  ): Page[] => {
11
+ const pageRange = Math.min(pageTotal, Math.max(1, numberOfPagesDisplayed));
12
12
  const pages: Page[] = [];
13
13
  let startingPage = 1;
14
14
  let endingPage = pageRange;
15
15
 
16
- if (pageTotal <= pageRange) {
17
- startingPage = 1;
18
- endingPage = pageRange;
19
- } else if (activePage + numberOfPagesDisplayed > pageTotal) {
20
- startingPage = pageTotal - (numberOfPagesDisplayed - 1);
21
- endingPage = startingPage + (numberOfPagesDisplayed - 1);
22
- } else if (
23
- activePage > numberOfPagesDisplayed &&
24
- activePage + numberOfPagesDisplayed <= pageTotal
25
- ) {
26
- startingPage = activePage - Math.floor(numberOfPagesDisplayed / 2);
27
- endingPage = startingPage + (numberOfPagesDisplayed - 1);
16
+ if (activePage + Math.floor(pageRange / 2) >= pageTotal) {
17
+ startingPage = Math.max(1, pageTotal - pageRange + 1);
18
+ endingPage = pageTotal;
19
+ } else {
20
+ startingPage = Math.max(1, activePage - Math.floor(pageRange / 2));
21
+ endingPage = Math.min(pageTotal, startingPage + pageRange - 1);
28
22
  }
29
23
 
30
24
  for (let i = startingPage; i <= endingPage; i += 1) {
31
25
  pages.push({ pageNumber: i, isPage: true });
32
26
  }
33
27
 
34
- if (pageTotal > pages[pages.length - 1]?.pageNumber) {
35
- const secondToLastPage =
36
- pageTotal !== activePage + numberOfPagesDisplayed
37
- ? activePage + numberOfPagesDisplayed
38
- : pageTotal - 1;
39
-
40
- // only add ellipsis if there are more than 0 pages between the final page and the rest of the pages
41
- if (pageTotal > numberOfPagesDisplayed + 1) {
42
- pages.push({ pageNumber: secondToLastPage, isPage: false });
28
+ // Handling ellipsis for overflow pages
29
+ if (endingPage < pageTotal) {
30
+ if (endingPage < pageTotal - 1) {
31
+ pages.push({ pageNumber: -1, isPage: false }); // represents ellipsis
43
32
  }
44
-
45
33
  pages.push({ pageNumber: pageTotal, isPage: true });
46
34
  }
47
35
 
48
- if (activePage > numberOfPagesDisplayed) {
49
- const threeDotsPage =
50
- activePage - numberOfPagesDisplayed > 1
51
- ? activePage - numberOfPagesDisplayed
52
- : activePage - numberOfPagesDisplayed + 1;
53
-
54
- pages.unshift(
55
- { pageNumber: 1, isPage: true },
56
- { pageNumber: threeDotsPage, isPage: false }
57
- );
36
+ if (startingPage > 1) {
37
+ pages.unshift({ pageNumber: 1, isPage: true });
38
+ if (startingPage > 2) {
39
+ pages.splice(1, 0, { pageNumber: -1, isPage: false }); // represents ellipsis
40
+ }
58
41
  }
59
42
 
60
- return [...pages];
43
+ return pages;
61
44
  };
62
45
 
63
- // Return the true page range in cases
64
- // where number of pages wanted for display is larger than the actual page total.
65
- export const generatePageRange = (
66
- numberOfPagesDisplayed: number,
67
- pageTotal: number
68
- ): number =>
69
- numberOfPagesDisplayed > pageTotal ? pageTotal : numberOfPagesDisplayed;
70
-
71
46
  export const generatePageTotal = (
72
47
  totalItemsCount: number,
73
48
  itemsPerPage: number
@@ -80,22 +55,9 @@ export const generateActiveListRange = (
80
55
  activePage: number,
81
56
  totalItemsCount: number,
82
57
  itemsPerPage: number
83
- ): { first?: number; last?: number } => {
84
- const activePageRange: { first?: number; last?: number } = {};
85
-
86
- const pageTotal = generatePageTotal(totalItemsCount, itemsPerPage);
87
-
88
- if (activePage === 1) {
89
- activePageRange.first = 1;
90
- activePageRange.last =
91
- totalItemsCount > itemsPerPage ? itemsPerPage : totalItemsCount;
92
- } else if (activePage < pageTotal) {
93
- activePageRange.first = activePage * itemsPerPage - (itemsPerPage - 1);
94
- activePageRange.last = activePage * itemsPerPage;
95
- } else {
96
- activePageRange.first = activePage * itemsPerPage - (itemsPerPage - 1);
97
- activePageRange.last = totalItemsCount;
98
- }
58
+ ): { first: number; last: number } => {
59
+ const first = (activePage - 1) * itemsPerPage + 1;
60
+ const last = Math.min(activePage * itemsPerPage, totalItemsCount);
99
61
 
100
- return activePageRange;
62
+ return { first, last };
101
63
  };
@@ -0,0 +1,25 @@
1
+ import { Canvas, Meta, ArgTypes } from '@storybook/blocks';
2
+ import * as Stories from './RangeInput.stories';
3
+ import {RangeInput} from "./RangeInput";
4
+
5
+ <Meta of={Stories} />
6
+
7
+ # RangeInput
8
+
9
+ Use a RangeInput when a user is required to select a value within a range. It is ideal for this scenario because the range is displayed without having to interact.
10
+
11
+ ## Props
12
+
13
+ <ArgTypes of={RangeInput} />
14
+
15
+ ## Default
16
+
17
+ All that is required to render a basic version of the RangeInput is the input's unique `id`, a `value`, a `max`, and an `onChange` event handler passed to the `onChange` prop.
18
+
19
+ <Canvas isExpanded of={Stories.Default} />
20
+
21
+ ### Disabled
22
+
23
+ Use the `isDisabled` prop to disable the input.
24
+
25
+ <Canvas of={Stories.Disabled} />
@@ -0,0 +1,25 @@
1
+ .slider {
2
+ appearance: none;
3
+ -webkit-appearance: none;
4
+ padding: 0 !important;
5
+ background-color: var(--color-base-grey-700);
6
+ border: none;
7
+ border-radius: 2.5rem;
8
+ height: var(--size-spacing-sm);
9
+ overflow: visible;
10
+ }
11
+ .slider::-webkit-slider-thumb {
12
+ -webkit-appearance: none;
13
+ appearance: none;
14
+ height: var(--size-spacing-2xl);
15
+ width: var(--size-spacing-2xl);
16
+ background-color: var(--color-base-white);
17
+ border-radius: var(--size-percentage-50);
18
+ border: 0.125rem solid #0f172a;
19
+ cursor: pointer;
20
+ }
21
+
22
+ .disabled {
23
+ cursor: not-allowed;
24
+ opacity: 0.5;
25
+ }
@@ -0,0 +1,43 @@
1
+ import React, { useState } from 'react';
2
+ import type { Meta } from '@storybook/react';
3
+ import { RangeInput } from './RangeInput';
4
+
5
+ const meta: Meta<typeof RangeInput> = {
6
+ title: 'Components/RangeInput',
7
+ component: RangeInput,
8
+ parameters: {
9
+ controls: { hideNoControlsWarning: true },
10
+ },
11
+ };
12
+
13
+ export default meta;
14
+
15
+ export const Default = () => {
16
+ const [value, setValue] = useState(50);
17
+
18
+ return (
19
+ <>
20
+ <RangeInput
21
+ id="range"
22
+ value={value}
23
+ max={100}
24
+ onChange={(event) => setValue(+event.target.value)}
25
+ />
26
+ <p>Value: {value}</p>
27
+ </>
28
+ );
29
+ };
30
+
31
+ export const Disabled = () => {
32
+ const [value, setValue] = useState(50);
33
+
34
+ return (
35
+ <RangeInput
36
+ id="range-disabled"
37
+ value={value}
38
+ max={100}
39
+ onChange={(event) => setValue(+event.target.value)}
40
+ isDisabled={true}
41
+ />
42
+ );
43
+ };
@@ -0,0 +1,36 @@
1
+ import React from 'react';
2
+ import { fireEvent, render, screen } from '@testing-library/react';
3
+ import '@testing-library/jest-dom';
4
+
5
+ import { InputRangeProps, RangeInput } from './RangeInput';
6
+
7
+ describe('RangeInput', () => {
8
+ const defaultProps: InputRangeProps = {
9
+ id: 'test-range',
10
+ value: 50,
11
+ max: 100,
12
+ onChange: jest.fn(),
13
+ };
14
+
15
+ test('should render the range input with correct attributes', () => {
16
+ render(<RangeInput {...defaultProps} />);
17
+ const rangeInput = screen.getByRole('slider');
18
+
19
+ expect(rangeInput).toBeInTheDocument();
20
+ expect(rangeInput).toHaveAttribute('id', 'test-range');
21
+ expect(rangeInput).toHaveAttribute('type', 'range');
22
+ expect(rangeInput).toHaveAttribute('min', '0');
23
+ expect(rangeInput).toHaveAttribute('value', '50');
24
+ expect(rangeInput).toHaveAttribute('max', '100');
25
+ });
26
+
27
+ test('should update the value when changed', async () => {
28
+ const onChangeMock = jest.fn();
29
+ render(<RangeInput {...defaultProps} onChange={onChangeMock} />);
30
+ const rangeInput = screen.getByRole('slider');
31
+
32
+ await fireEvent.change(rangeInput, { target: { value: '75' } });
33
+
34
+ expect(onChangeMock).toHaveBeenCalledTimes(1);
35
+ });
36
+ });
@@ -0,0 +1,65 @@
1
+ import React from 'react';
2
+ import { FC } from 'react';
3
+ import classNames from 'classnames';
4
+ import styles from './RangeInput.module.scss';
5
+
6
+ export interface InputRangeProps {
7
+ /**
8
+ * The input's id attribute.
9
+ */
10
+ id: string;
11
+ /**
12
+ * The value of the range.
13
+ */
14
+ value: number;
15
+ /**
16
+ * The maximum value of the range.
17
+ */
18
+ max: number;
19
+ /**
20
+ * Callback function to call on change event.
21
+ */
22
+ onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
23
+ /**
24
+ * Custom class to be added to standard input classes.
25
+ */
26
+ className?: string;
27
+ /**
28
+ * If the input should be disabled and not focusable.
29
+ */
30
+ isDisabled?: boolean;
31
+ }
32
+
33
+ export const RangeInput: FC<InputRangeProps> = ({
34
+ value = 0,
35
+ max = 0,
36
+ id,
37
+ onChange,
38
+ className,
39
+ isDisabled = false,
40
+ ...restProps
41
+ }) => {
42
+ const currentProgress = value > 0 ? (value / max) * 100 : 0;
43
+
44
+ return (
45
+ <input
46
+ {...restProps}
47
+ id={id}
48
+ type="range"
49
+ min="0"
50
+ value={value}
51
+ max={max}
52
+ aria-valuemax={max}
53
+ aria-valuenow={value}
54
+ aria-label="range input"
55
+ className={classNames(styles.slider, className, {
56
+ [styles.disabled]: isDisabled,
57
+ })}
58
+ onChange={onChange}
59
+ disabled={isDisabled}
60
+ style={{
61
+ background: `linear-gradient(to right, var(--color-base-grey-400) ${currentProgress}%, var(--color-base-grey-700) ${currentProgress}%)`,
62
+ }}
63
+ />
64
+ );
65
+ };
package/src/index.ts CHANGED
@@ -30,6 +30,7 @@ export * from './components/Modal/Modal';
30
30
  export * from './components/Pagination/Pagination';
31
31
  export * from './components/Popover/Popover';
32
32
  export * from './components/RadioGroup/RadioGroup';
33
+ export * from './components/RangeInput/RangeInput';
33
34
  export * from './components/ResponsiveProvider/ResponsiveProvider';
34
35
  export * from './components/SelectInput/SelectInput';
35
36
  export * from './components/SelectInputInset/SelectInputInset';