diff --git a/index.d.ts b/index.d.ts index b099cea..d692b55 100644 --- a/index.d.ts +++ b/index.d.ts @@ -3,6 +3,7 @@ import { type ComponentClass, type HTMLProps, type ReactNode, + type JSX, } from 'react'; /** @@ -358,5 +359,97 @@ body.dark-mode { */ export class BodyClass extends ReactComponent {} +/** +Inserts a separator between each element of the children. + +@param children - The elements to intersperse with separators. +@param separator - The separator to insert between elements. Can be a ReactNode or a function that returns a ReactNode. + +@example +``` +import {intersperse} from 'react-extras'; + +const items = ['Apple', 'Orange', 'Banana']; +const list = intersperse( + items.map(item =>
  • {item}
  • ), + ', ' +); +// => [
  • Apple
  • , ', ',
  • Orange
  • , ', ',
  • Banana
  • ] +``` + +@example +``` +import {intersperse} from 'react-extras'; + +const items = ['Apple', 'Orange', 'Banana']; +const list = intersperse( + items.map(item =>
  • {item}
  • ), + (index, count) => index === count - 2 ? ' and ' : ', ' +); +// => [
  • Apple
  • , ', ',
  • Orange
  • , ' and ',
  • Banana
  • ] +``` +*/ +export function intersperse( + children: ReactNode, + separator?: ReactNode | ((index: number, count: number) => ReactNode) +): ReactNode[]; + +type JoinProps = { + /** + The separator to insert between elements. + + Can be a ReactNode or a function that returns a ReactNode. + + @default ', ' + */ + readonly separator?: ReactNode | ((index: number, count: number) => ReactNode); + + /** + The elements to join with separators. + */ + readonly children: ReactNode; +}; + +/** +React component that renders the children with a separator between each element. + +@example +``` +import {Join} from 'react-extras'; + + +
  • Apple
  • +
  • Orange
  • +
  • Banana
  • +
    +// =>
  • Apple
  • ,
  • Orange
  • ,
  • Banana
  • +``` + +@example +``` +import {Join} from 'react-extras'; + + + Home + About + Contact + +// => Home | About | Contact +``` + +@example +``` +import {Join} from 'react-extras'; + + index === count - 2 ? ' and ' : ', '}> + Apple + Orange + Banana + +// => Apple, Orange and Banana +``` +*/ +export function Join(props: JoinProps): JSX.Element; + export {default as classNames} from '@sindresorhus/class-names'; export {default as autoBind} from 'auto-bind/react'; diff --git a/readme.md b/readme.md index e788ce5..f9d60e1 100644 --- a/readme.md +++ b/readme.md @@ -245,6 +245,88 @@ body.dark-mode { } ``` +### intersperse(children, separator?) + +Inserts a separator between each element of the children. + +#### children + +Type: `ReactNode` + +The elements to intersperse with separators. + +#### separator + +Type: `ReactNode | ((index: number, count: number) => ReactNode)`\ +Default: `', '` + +The separator to insert between elements. Can be a React node or a function that returns a React node. + +```jsx +import {intersperse} from 'react-extras'; + +const items = ['Apple', 'Orange', 'Banana']; +const list = intersperse( + items.map(item =>
  • {item}
  • ), + ', ' +); +// => [
  • Apple
  • , ', ',
  • Orange
  • , ', ',
  • Banana
  • ] +``` + +With a function separator: + +```jsx +import {intersperse} from 'react-extras'; + +const items = ['Apple', 'Orange', 'Banana']; +const list = intersperse( + items.map(item =>
  • {item}
  • ), + (index, count) => index === count - 2 ? ' and ' : ', ' +); +// => [
  • Apple
  • , ', ',
  • Orange
  • , ' and ',
  • Banana
  • ] +``` + +### `` + +React component that renders the children with a separator between each element. + +```jsx +import {Join} from 'react-extras'; + + +
  • Apple
  • +
  • Orange
  • +
  • Banana
  • +
    +// =>
  • Apple
  • ,
  • Orange
  • ,
  • Banana
  • +``` + +With a custom separator: + +```jsx +import {Join} from 'react-extras'; + + + Home + About + Contact + +// => Home | About | Contact +``` + +With a function separator: + +```jsx +import {Join} from 'react-extras'; + + index === count - 2 ? ' and ' : ', '}> + Apple + Orange + Banana + +// => Apple, Orange and Banana +``` + ### isStatelessComponent(Component) Returns a boolean of whether the given `Component` is a [functional stateless component](https://javascriptplayground.com/functional-stateless-components-react/). diff --git a/source/index.js b/source/index.js index a1f6afa..b1596e7 100644 --- a/source/index.js +++ b/source/index.js @@ -25,3 +25,4 @@ export {default as For} from './for.js'; export {default as Image} from './image.js'; export {default as RootClass} from './root-class.js'; export {default as BodyClass} from './body-class.js'; +export {intersperse, Join} from './intersperse.js'; diff --git a/source/intersperse.js b/source/intersperse.js new file mode 100644 index 0000000..908e0e3 --- /dev/null +++ b/source/intersperse.js @@ -0,0 +1,46 @@ +import React, {Children, Fragment} from 'react'; + +export function intersperse( + children, + separator = ', ', +) { + const items = Children.toArray(children); + + const count = items.length; + + // Short-circuit if no separators needed or separator is falsy (and not a function) + // Note: undefined check won't trigger due to default parameter, but null and false will + if (count <= 1 || separator === null || separator === false) { + return items; + } + + const result = []; + for (const [index, child] of items.entries()) { + result.push(child); + + // Early return on last item + if (index === count - 1) { + return result; + } + + const separatorNode + = typeof separator === 'function' ? separator(index, count) : separator; + + if (separatorNode === undefined || separatorNode === null || separatorNode === false) { + continue; + } + + result.push( + {separatorNode}, + ); + } + + return result; +} + +export function Join({ + separator, + children, +}) { + return <>{intersperse(children, separator)}; +} diff --git a/test-d/index.test-d.tsx b/test-d/index.test-d.tsx index c5fc97b..f0cd166 100644 --- a/test-d/index.test-d.tsx +++ b/test-d/index.test-d.tsx @@ -13,6 +13,8 @@ import { Image, RootClass, BodyClass, + intersperse, + Join, } from '../index.js'; class Bar extends ReactComponent { @@ -85,3 +87,45 @@ const RootTest = (props: {isDarkMode: boolean}) => ( ); + +// Test intersperse function +const items = ['Apple', 'Orange', 'Banana'].map(item =>
  • {item}
  • ); + +// Test with array of ReactNodes +expectType(intersperse(items, ', ')); + +// Test with single ReactNode +expectType(intersperse(
    single
    , ', ')); + +// Test with separator function +expectType(intersperse(items, (index, count) => + index === count - 2 ? ' and ' : ', ', +)); + +// Test with no separator +expectType(intersperse(items)); + +// Test Join component +const JoinTest = ( + +
  • Apple
  • +
  • Orange
  • +
  • Banana
  • +
    +); + +const JoinWithCustomSeparator = ( + + Home + About + Contact + +); + +const JoinWithFunctionSeparator = ( + index === count - 2 ? ' and ' : ', '}> + Apple + Orange + Banana + +); diff --git a/test/test.js b/test/test.js index 3bdb36a..77825c6 100644 --- a/test/test.js +++ b/test/test.js @@ -14,6 +14,8 @@ import { BodyClass, isStatelessComponent, getDisplayName, + intersperse, + Join, } from '../dist/index.js'; const renderIntoDocument = element => { @@ -179,3 +181,92 @@ test('getDisplayName()', t => { t.is(getDisplayName(BodyClass), 'BodyClass'); t.is(getDisplayName(() => {}), 'Component'); }); + +test('intersperse()', t => { + // Basic usage with default separator + const result1 = intersperse(['a', 'b', 'c']); + t.is(result1.length, 5); + t.is(result1[0], 'a'); + t.is(result1[2], 'b'); + t.is(result1[4], 'c'); + + // Custom separator + const result2 = intersperse(['a', 'b', 'c'], ' | '); + t.is(result2.length, 5); + t.is(result2[1].props.children, ' | '); + t.is(result2[3].props.children, ' | '); + + // Function separator + const result3 = intersperse(['a', 'b', 'c'], (index, count) => + index === count - 2 ? ' and ' : ', ', + ); + t.is(result3.length, 5); + t.is(result3[1].props.children, ', '); + t.is(result3[3].props.children, ' and '); + + // Function returning falsy values + const result4 = intersperse(['a', 'b', 'c', 'd'], index => + index === 0 ? null : ' - ', + ); + t.is(result4.length, 6); // 'a', 'b', ' - ', 'c', ' - ', 'd' + t.is(result4[0], 'a'); + t.is(result4[1], 'b'); + t.is(result4[2].props.children, ' - '); + + // Falsy separators (should short-circuit) + t.deepEqual(intersperse(['a', 'b', 'c'], null), ['a', 'b', 'c']); + t.deepEqual(intersperse(['a', 'b', 'c'], false), ['a', 'b', 'c']); + + // Single item (no separators needed) + t.deepEqual(intersperse(['a']), ['a']); + + // Empty array + t.deepEqual(intersperse([]), []); + + // React elements + const elements = [ + React.createElement('span', {key: '1'}, 'A'), + React.createElement('span', {key: '2'}, 'B'), + React.createElement('span', {key: '3'}, 'C'), + ]; + const result5 = intersperse(elements, ' · '); + t.is(result5.length, 5); + // React adds prefix to keys, so check they end with our keys + t.regex(String(result5[0].key), /1$/); + t.regex(String(result5[2].key), /2$/); + t.regex(String(result5[4].key), /3$/); + t.is(result5[1].props.children, ' · '); + + // Separator as React element + const result6 = intersperse(['a', 'b', 'c'], React.createElement('hr')); + t.is(result6.length, 5); + t.is(result6[1].props.children.type, 'hr'); +}); + +test('', t => { + // Default separator + const html1 = verifyRenders(t, + Apple + Orange + Banana + ); + t.regex(html1, /Apple/); + t.regex(html1, /Orange/); + t.regex(html1, /Banana/); + + // Custom separator + const html2 = verifyRenders(t, + Home + About + Contact + ); + t.regex(html2, /Home/); + t.regex(html2, /About/); + t.regex(html2, /Contact/); + + // Single child + const html3 = verifyRenders(t, +
    Only child
    +
    ); + t.regex(html3, /Only child/); +});