diff --git a/README.md b/README.md
index 5f88b415a1..5546bd39ae 100644
--- a/README.md
+++ b/README.md
@@ -39,6 +39,7 @@ To contribute please see [contributing.md](CONTRIBUTING.md).
[`bpk-component-card`](/packages/bpk-component-card)
[`bpk-component-checkbox`](/packages/bpk-component-checkbox)
[`bpk-component-chip`](/packages/bpk-component-chip)
+[`bpk-component-chip-group`](/packages/bpk-component-chip-group)
[`bpk-component-close-button`](/packages/bpk-component-close-button)
[`bpk-component-code`](/packages/bpk-component-code)
[`bpk-component-datatable`](/packages/bpk-component-datatable)
diff --git a/examples/bpk-component-chip-group/examples.module.scss b/examples/bpk-component-chip-group/examples.module.scss
new file mode 100644
index 0000000000..b7bdab4e41
--- /dev/null
+++ b/examples/bpk-component-chip-group/examples.module.scss
@@ -0,0 +1,47 @@
+/*
+ * Backpack - Skyscanner's Design System
+ *
+ * Copyright 2016 Skyscanner Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@import '../../packages/bpk-mixins/index.scss';
+
+.bpk-chip-group-examples {
+ &__fixed-width {
+ width: 300 * $bpk-one-pixel-rem;
+ }
+
+ &__contrast {
+ padding: bpk-spacing-base();
+ background-color: $bpk-canvas-contrast-day;
+ }
+
+ &__dark {
+ padding: bpk-spacing-base();
+ background-color: $bpk-surface-contrast-day;
+ }
+
+ &__image {
+ padding: bpk-spacing-base();
+ background-image: url('https://content.skyscnr.com/96508dbac15a2895b0147dc7e7f9ad30/canadian-rockies-canada.jpg');
+ }
+
+ &__mixed-container {
+ h2 {
+ margin-top: bpk-spacing-xl();
+ margin-bottom: bpk-spacing-md();
+ }
+ }
+}
diff --git a/examples/bpk-component-chip-group/examples.tsx b/examples/bpk-component-chip-group/examples.tsx
new file mode 100644
index 0000000000..9e84156b5b
--- /dev/null
+++ b/examples/bpk-component-chip-group/examples.tsx
@@ -0,0 +1,365 @@
+/*
+ * Backpack - Skyscanner's Design System
+ *
+ * Copyright 2016 Skyscanner Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+
+import { useState } from 'react';
+
+import { CHIP_TYPES } from '../../packages/bpk-component-chip';
+import BpkMultiSelectChipGroup, {
+ BpkSingleSelectChipGroup,
+ CHIP_GROUP_TYPES,
+ CHIP_COMPONENT
+} from '../../packages/bpk-component-chip-group';
+import BpkText, { TEXT_STYLES } from '../../packages/bpk-component-text/index';
+import { cssModules } from '../../packages/bpk-react-utils/index';
+
+import type {
+ MultiSelectProps,
+ ChipItem,
+ SingleSelectProps} from '../../packages/bpk-component-chip-group';
+
+import STYLES from './examples.module.scss';
+
+const getClassName = cssModules(STYLES);
+
+const BpkMultiSelectChipGroupState = ({ chips, ...rest }: MultiSelectProps) => {
+ const [selectedChips, setSelectedChips] = useState(chips.map(c => Boolean(c.selected)));
+
+ const statefulChips = chips.map((chip, index) => chip && ({
+ ...chip,
+ selected: selectedChips[index],
+ onClick: (selected: boolean, selectedIndex: number) => {
+ if (chip.onClick) {
+ chip.onClick(selected, selectedIndex);
+ }
+
+ const nextSelectedChips = [...selectedChips];
+ nextSelectedChips[selectedIndex] = selected;
+ setSelectedChips(nextSelectedChips);
+ },
+ }));
+
+ return ;
+};
+
+const BpkSingleSelectChipGroupState = ({
+ onItemClick,
+ selectedIndex: initiallySelectedIndex = -1,
+ ...rest
+ }: SingleSelectProps) => {
+ const [selectedIndex, setSelectedIndex] = useState(initiallySelectedIndex);
+
+ const onItemClickWithState = (item: ChipItem, selected: boolean, index: number) => {
+ if (onItemClick) {
+ onItemClick(item, selected, index);
+ }
+ setSelectedIndex(selected ? index : -1);
+ };
+
+ return ;
+};
+
+const chips = [
+ {
+ text: 'London',
+ },
+ {
+ text: 'Berlin',
+ selected: true,
+ },
+ {
+ text: 'Florence',
+ },
+ {
+ text: 'Stockholm',
+ },
+ {
+ text: 'Copenhagen',
+ },
+ {
+ text: 'Salzburg',
+ },
+ {
+ text: 'Graz',
+ },
+ {
+ text: 'Lanzarote',
+ },
+ {
+ text: 'Valencia',
+ },
+ {
+ text: 'Reykjavik',
+ },
+ {
+ text: 'Tallinn',
+ },
+ {
+ text: 'Sofia',
+ },
+];
+
+
+export const BpkChipGroupWrapping = () => (
+
+
+
+);
+
+export const BpkSingleChipGroupWrapping = () => (
+
+
+
+);
+
+
+export const BpkChipGroupRail = () => (
+
+
+
+);
+
+
+export const BpkChipGroupSticky = () => {
+ const stickyChip = {
+ text: 'Sort & Filter',
+ };
+
+ return (
+
+
+
+ );
+};
+
+export const OnContrastChipGroup = () => {
+ const stickyChip = {
+ text: 'Sort & Filter',
+ };
+
+ return (
+
+
+
+ );
+};
+
+
+export const OnDarkChipGroup = () => {
+ const stickyChip = {
+ text: 'Sort & Filter',
+ };
+
+ return (
+
+
+
+ );
+};
+
+export const OnImageChipGroup = () => {
+ const stickyChip = {
+ text: 'Sort & Filter',
+ };
+
+ return (
+
+
+
+ );
+};
+
+export const BpkChipGroupWithLabel = () => (
+
+
+
+);
+
+export const AllChipTypesGroup = () => {
+ const [dismissed, setDismissed] = useState(false);
+
+ const allChips = [
+ {
+ text: 'Disabled',
+ disabled: true,
+ },
+ {
+ text: 'Dismissible',
+ onClick: () => setDismissed(true),
+ component: CHIP_COMPONENT.dismissible,
+ hidden: dismissed,
+ },
+ {
+ text: 'Dropdown',
+ component: CHIP_COMPONENT.dropdown,
+ },
+ {
+ text: 'Selectable',
+ },
+ {
+ text: 'Initially selected',
+ selected: true,
+ },
+ ];
+
+ return (
+
+ );
+};
+
+
+export const StateManagement = () => {
+ const [route, setRoute] = useState('flights');
+
+ return (
+ setRoute('flights'),
+ }, {
+ text: 'Car Hire',
+ selected: route === 'cars',
+ onClick: () => setRoute('cars'),
+ }, {
+ text: 'Hotels',
+ selected: route === 'hotels',
+ onClick: () => setRoute('hotels'),
+ }, {
+ text: 'Trains',
+ selected: route === 'trains',
+ onClick: () => setRoute('trains'),
+ }, {
+ component: CHIP_COMPONENT.dropdown,
+ text: 'More',
+ accessibilityLabel: 'Show more filter options',
+ // eslint-disable-next-line no-console
+ onClick: (selected) => console.log(`Open dropdown: ${selected}`),
+ }]}
+ />
+ );
+};
+
+export const MixedExample = () => (
+
+
+ Rail
+
+
+
+ Rail with sticky chip
+
+
+
+ On Contrast
+
+
+
+ On Dark
+
+
+
+ On Image
+
+
+
+ With Label
+
+
+
+ Wrapped
+
+
+
+ All chip types
+
+
+
+ Single Select Group
+
+
+
+ State example
+
+
+
+
+);
diff --git a/examples/bpk-component-chip-group/stories.ts b/examples/bpk-component-chip-group/stories.ts
new file mode 100644
index 0000000000..5680cd566c
--- /dev/null
+++ b/examples/bpk-component-chip-group/stories.ts
@@ -0,0 +1,62 @@
+/*
+ * Backpack - Skyscanner's Design System
+ *
+ * Copyright 2016 Skyscanner Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import BpkMultiSelectChipGroup, {
+ BpkSingleSelectChipGroup,
+} from '../../packages/bpk-component-chip-group';
+
+import {
+ BpkChipGroupRail,
+ BpkChipGroupWrapping,
+ BpkChipGroupSticky,
+ BpkSingleChipGroupWrapping,
+ OnDarkChipGroup,
+ OnImageChipGroup,
+ BpkChipGroupWithLabel,
+ MixedExample,
+ AllChipTypesGroup,
+ OnContrastChipGroup,
+ StateManagement,
+} from './examples';
+
+export default {
+ title: 'bpk-component-chip-group',
+ component: BpkMultiSelectChipGroup,
+ subcomponents: {
+ BpkChipGroupSingleSelect: BpkSingleSelectChipGroup,
+ // TODO: can we show the shape of ChipItem here?
+ },
+};
+
+export const WrappedChipGroup = BpkChipGroupWrapping;
+export const SingleSelectChipGroup = BpkSingleChipGroupWrapping;
+export const RailChipGroup = BpkChipGroupRail;
+export const StickyChipGroup = BpkChipGroupSticky;
+export const OnContrast = OnContrastChipGroup;
+export const OnDark = OnDarkChipGroup;
+export const OnImage = OnImageChipGroup;
+export const WithLabel = BpkChipGroupWithLabel;
+export const AllChipTypes = AllChipTypesGroup;
+export const ExampleStateManagement = StateManagement;
+export const VisualTest = MixedExample;
+export const VisualTestWithZoom = {
+ render: VisualTest,
+ args: {
+ zoomEnabled: true
+ },
+};
diff --git a/packages/bpk-component-chip-group/README.md b/packages/bpk-component-chip-group/README.md
new file mode 100644
index 0000000000..72d84d3a19
--- /dev/null
+++ b/packages/bpk-component-chip-group/README.md
@@ -0,0 +1,112 @@
+# bpk-component-chip-group
+
+> Backpack chip group component.
+
+## Installation
+
+Check the main [Readme](https://github.com/skyscanner/backpack#usage) for a complete installation guide.
+
+## Usage
+
+### BpkMultiSelectChipGroup
+
+This is a multiselectable chip group without any built in state management. State of chips must be managed by the consumer as passed in through the `chips` prop, using the `onClick` property of each chip to detect interaction. See [stories.tsx](/examples/bpk-component-chip-group/examples.tsx) for an example of how to manage state of chips.
+
+```tsx
+import BpkMultiSelectChipGroup, {
+ BpkChipGroupState,
+ BpkChipGroupSingleSelectState,
+ CHIP_GROUP_TYPES,
+ CHIP_COMPONENT,
+} from '@skyscanner/backpack-web/bpk-component-chip-group';
+import { CHIP_TYPES } from '@skyscanner/backpack-web/bpk-component-chip';
+import { useState } from 'react';
+
+const MainExample = () => (
+ console.log(`Open dropdown: ${selected}`),
+ }]}
+ />
+);
+
+const VerticalsExample = () => {
+ const [route, setRoute] = useState('flights');
+
+ return (
+ setRoute('flights'),
+ }, {
+ text: 'Car Hire',
+ selected: route === 'cars',
+ onClick: () => setRoute('cars'),
+ }, {
+ text: 'Hotels',
+ selected: route === 'hotels',
+ onClick: () => setRoute('hotels'),
+ }, {
+ text: 'Trains',
+ selected: route === 'trains',
+ onClick: () => setRoute('trains'),
+ }, {
+ component: CHIP_COMPONENT.dropdown,
+ text: 'More',
+ accessibilityLabel: 'Show more filter options',
+ onClick: (selected) => console.log(`Open dropdown: ${selected}`),
+ }]}
+ />
+ );
+};
+```
+
+### BpkSingleSelectChipGroup
+
+This is a wrapper around a `BpkChipGroup` that only allows a single chip to be `selected`, determined by the `selectedIndex` prop. If no chips should appear selected, this should be `undefined`. State of selected chips should be managed using the `onItemClick` prop.
+
+```tsx
+const SingleSelectExample = () => {
+ const [selectedIndex, setSelectedIndex] = useState(2);
+
+ return (
+ { setSelectedIndex(selected ? index : undefined) }}
+ />
+ );
+};
+```
+
+## Props
+
+Check out the full list of props on Skyscanner's [design system documentation website](https://www.skyscanner.design/latest/components/chip-group/web-4eQsMvYv).
diff --git a/packages/bpk-component-chip-group/index.ts b/packages/bpk-component-chip-group/index.ts
new file mode 100644
index 0000000000..65522e9cd2
--- /dev/null
+++ b/packages/bpk-component-chip-group/index.ts
@@ -0,0 +1,41 @@
+/*
+ * Backpack - Skyscanner's Design System
+ *
+ * Copyright 2016 Skyscanner Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import BpkMultiSelectChipGroup, {
+ type MultiSelectProps,
+ type ChipItem,
+ type SingleSelectChipItem,
+ CHIP_COMPONENT,
+ CHIP_GROUP_TYPES,
+} from './src/BpkMultiSelectChipGroup';
+import BpkSingleSelectChipGroup, {
+ type SingleSelectProps,
+} from './src/BpkSingleSelectChipGroup';
+
+export type {
+ ChipItem,
+ MultiSelectProps,
+ SingleSelectProps,
+ SingleSelectChipItem,
+};
+export {
+ BpkSingleSelectChipGroup,
+ CHIP_GROUP_TYPES,
+ CHIP_COMPONENT,
+};
+export default BpkMultiSelectChipGroup;
diff --git a/packages/bpk-component-chip-group/src/BpkChipGroup.module.scss b/packages/bpk-component-chip-group/src/BpkChipGroup.module.scss
new file mode 100644
index 0000000000..48a50611d3
--- /dev/null
+++ b/packages/bpk-component-chip-group/src/BpkChipGroup.module.scss
@@ -0,0 +1,66 @@
+/*
+ * Backpack - Skyscanner's Design System
+ *
+ * Copyright 2016 Skyscanner Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@use '../../unstable__bpk-mixins/borders';
+@use '../../unstable__bpk-mixins/tokens';
+@use '../../unstable__bpk-mixins/utils';
+
+.bpk-chip-group-container {
+ display: flex;
+ margin: 0 (- tokens.bpk-spacing-sm());
+ align-items: center;
+ white-space: nowrap;
+}
+
+.bpk-chip-group {
+ display: flex;
+ margin: 0;
+ padding: tokens.bpk-spacing-sm();
+ align-items: baseline;
+ border: none;
+ gap: tokens.bpk-spacing-md();
+
+ &--wrap {
+ padding-top: 0;
+ padding-bottom: 0;
+ flex-wrap: wrap;
+ }
+}
+
+.bpk-sticky-chip-container:first-child {
+ margin-inline-start: tokens.bpk-spacing-sm();
+}
+
+.bpk-sticky-chip-container {
+ margin-inline-end: tokens.bpk-spacing-sm();
+ padding-inline-end: tokens.bpk-spacing-md();
+
+ @include borders.bpk-border-right-sm(tokens.$bpk-line-day);
+
+ @include utils.bpk-rtl {
+ @include borders.bpk-border-left-sm(tokens.$bpk-line-day);
+ }
+
+ &--on-dark {
+ @include borders.bpk-border-right-sm(tokens.$bpk-line-on-dark-day);
+
+ @include utils.bpk-rtl {
+ @include borders.bpk-border-left-sm(tokens.$bpk-line-on-dark-day);
+ }
+ }
+}
diff --git a/packages/bpk-component-chip-group/src/BpkMultiSelectChipGroup-test.tsx b/packages/bpk-component-chip-group/src/BpkMultiSelectChipGroup-test.tsx
new file mode 100644
index 0000000000..74ebf86969
--- /dev/null
+++ b/packages/bpk-component-chip-group/src/BpkMultiSelectChipGroup-test.tsx
@@ -0,0 +1,105 @@
+/*
+ * Backpack - Skyscanner's Design System
+ *
+ * Copyright 2016 Skyscanner Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import '@testing-library/jest-dom';
+
+import BpkMultiSelectChipGroup, { CHIP_GROUP_TYPES } from './BpkMultiSelectChipGroup';
+
+const defaultProps = {
+ type: CHIP_GROUP_TYPES.wrap,
+ ariaLabel: 'a11y label',
+}
+
+describe('BpkMultiSelectChipGroup', () => {
+ beforeEach(() => {
+ window.matchMedia = jest.fn().mockImplementation(() => ({
+ matches: true,
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ }));
+ });
+
+ const chips = [
+ {
+ text: 'London',
+ },
+ {
+ text: 'Berlin',
+ selected: true,
+ },
+ {
+ text: 'Florence',
+ },
+ {
+ text: 'Stockholm',
+ }
+ ];
+
+ it('should render selected chip', () => {
+ render();
+
+ const chip = screen.getByRole('checkbox', { name: 'Berlin' });
+
+ expect(chip).toHaveClass('bpk-chip--default-selected');
+ });
+
+ it('should render correctly with sticky chip', () => {
+ render(
+ ,
+ );
+ expect(screen.getByRole('button', { name: 'Sort & Filter' })).toBeVisible();
+ });
+
+ it('should call onClick property of chip when clicked', async () => {
+ const user = userEvent.setup();
+
+ const onClick = jest.fn();
+
+ render(
+ ,
+ );
+
+ await user.click(screen.getByText('Berlin'));
+
+ expect(onClick).toHaveBeenCalledTimes(1);
+ expect(onClick).toHaveBeenCalledWith(true, 1);
+ });
+});
diff --git a/packages/bpk-component-chip-group/src/BpkMultiSelectChipGroup.tsx b/packages/bpk-component-chip-group/src/BpkMultiSelectChipGroup.tsx
new file mode 100644
index 0000000000..9a19f475fe
--- /dev/null
+++ b/packages/bpk-component-chip-group/src/BpkMultiSelectChipGroup.tsx
@@ -0,0 +1,237 @@
+/*
+ * Backpack - Skyscanner's Design System
+ *
+ * Copyright 2016 Skyscanner Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import type { ReactNode } from 'react';
+import { useRef } from 'react';
+
+import BpkBreakpoint, { BREAKPOINTS } from '../../bpk-component-breakpoint';
+import BpkSelectableChip, { BpkDismissibleChip, BpkIconChip, BpkDropdownChip, CHIP_TYPES } from '../../bpk-component-chip';
+import FilterIconSm from '../../bpk-component-icon/sm/filter';
+// @ts-expect-error Untyped import. See `decisions/imports-ts-suppressions.md`.
+import BpkMobileScrollContainer from '../../bpk-component-mobile-scroll-container';
+import BpkText, { TEXT_STYLES } from '../../bpk-component-text/src/BpkText';
+import { cssModules } from '../../bpk-react-utils';
+
+import Nudger, { POSITION } from './Nudger';
+
+import STYLES from './BpkChipGroup.module.scss';
+
+const getClassName = cssModules(STYLES);
+
+export const CHIP_GROUP_TYPES = {
+ rail: 'rail',
+ wrap: 'wrap',
+} as const;
+
+export const CHIP_COMPONENT = {
+ selectable: 'selectable',
+ dismissible: 'dismissible',
+ dropdown: 'dropdown',
+ icon: 'icon',
+} as const;
+
+const CHIP_COMPONENT_MAP = {
+ [CHIP_COMPONENT.selectable]: BpkSelectableChip,
+ [CHIP_COMPONENT.dismissible]: BpkDismissibleChip,
+ [CHIP_COMPONENT.dropdown]: BpkDropdownChip,
+ [CHIP_COMPONENT.icon]: BpkIconChip,
+}
+
+export type ChipGroupType = (typeof CHIP_GROUP_TYPES)[keyof typeof CHIP_GROUP_TYPES];
+export type ChipStyleType = (typeof CHIP_TYPES)[keyof typeof CHIP_TYPES];
+export type ChipComponentType = (typeof CHIP_COMPONENT)[keyof typeof CHIP_COMPONENT];
+
+export type SingleSelectChipItem = {
+ text: string;
+ accessibilityLabel?: string;
+ leadingAccessoryView?: ReactNode;
+ [rest: string]: any; // Inexact rest. See decisions/inexact-rest.md
+};
+
+export type ChipItem = {
+ component?: ChipComponentType;
+ onClick?: (selected: boolean, index: number) => void;
+ selected?: boolean;
+ hidden?: boolean;
+} & SingleSelectChipItem;
+
+type CommonProps = {
+ label?: string;
+ ariaLabel?: string;
+ chipStyle?: ChipStyleType;
+ chips: ChipItem[];
+ ariaMultiselectable?: boolean;
+};
+
+type RailChipGroupProps = {
+ stickyChip?: ChipItem;
+ leadingNudgerLabel: string;
+ trailingNudgerLabel: string;
+} & CommonProps;
+
+type WrapChipGroupProps = {
+} & CommonProps;
+
+export type MultiSelectProps = (RailChipGroupProps & { type: typeof CHIP_GROUP_TYPES.rail } | WrapChipGroupProps & { type: typeof CHIP_GROUP_TYPES.wrap });
+
+const Chip = (
+ { ariaMultiselectable,
+ chipIndex,
+ chipItem,
+ chipStyle }:
+ {
+ chipIndex: number,
+ chipItem: ChipItem,
+ chipStyle: ChipStyleType,
+ ariaMultiselectable: boolean,
+ }) => {
+ const {
+ accessibilityLabel,
+ component = CHIP_COMPONENT.selectable,
+ hidden = false,
+ leadingAccessoryView = null,
+ onClick,
+ selected,
+ text,
+ ...rest
+ } = chipItem;
+ const Component = CHIP_COMPONENT_MAP[component];
+ return hidden ? null : (
+ {
+ if (onClick) {
+ onClick(!selected, chipIndex);
+ }
+ }}
+ role={ariaMultiselectable ? 'checkbox' : 'radio'}
+ leadingAccessoryView={leadingAccessoryView}
+ {...rest}
+ >
+ {text}
+
+ );
+}
+
+const ChipGroupContent = (
+ { ariaLabel,
+ ariaMultiselectable,
+ chipGroupClassNames,
+ chipStyle,
+ chips,
+ label }:
+ {
+ chipGroupClassNames: string,
+ ariaMultiselectable: boolean,
+ ariaLabel?: string,
+ label?: string,
+ chips: ChipItem[],
+ chipStyle: ChipStyleType,
+ }) => (
+
+);
+
+const RailChipGroup = ({
+ ariaLabel,
+ ariaMultiselectable = true,
+ chipStyle = CHIP_TYPES.default,
+ chips,
+ label,
+ leadingNudgerLabel,
+ stickyChip,
+ trailingNudgerLabel,
+}: RailChipGroupProps) => {
+ const scrollContainerRef = useRef(null);
+
+ const stickyChipContainerClassnames = getClassName(
+ 'bpk-sticky-chip-container',
+ `bpk-sticky-chip-container--${chipStyle}`,
+ );
+ const chipGroupClassNames = getClassName(
+ 'bpk-chip-group',
+ 'bpk-chip-group--rail',
+ );
+
+ return (
+ <>
+
+
+
+ {stickyChip &&
+
+
+ {(isDesktop) =>
+ ,
+ ...stickyChip,
+ })} chipStyle={chipStyle} ariaMultiselectable={ariaMultiselectable} chipIndex={-1} />
+ }
+
+
+ }
+ { scrollContainerRef.current = el }}
+ >
+
+
+
+
+
+ >
+ );
+};
+
+const WrapChipGroup = ({
+ ariaLabel,
+ ariaMultiselectable = true,
+ chipStyle = CHIP_TYPES.default,
+ chips,
+ label,
+}: WrapChipGroupProps) =>
+ ;
+
+const BpkMultiSelectChipGroup = (props: MultiSelectProps) => (
+
+ {props.type === CHIP_GROUP_TYPES.rail ? : }
+
+);
+
+export default BpkMultiSelectChipGroup;
diff --git a/packages/bpk-component-chip-group/src/BpkSingleSelectChipGroup-test.tsx b/packages/bpk-component-chip-group/src/BpkSingleSelectChipGroup-test.tsx
new file mode 100644
index 0000000000..c276dd56ec
--- /dev/null
+++ b/packages/bpk-component-chip-group/src/BpkSingleSelectChipGroup-test.tsx
@@ -0,0 +1,101 @@
+/*
+ * Backpack - Skyscanner's Design System
+ *
+ * Copyright 2016 Skyscanner Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { CHIP_GROUP_TYPES } from './BpkMultiSelectChipGroup';
+import BpkSingleSelectChipGroup from './BpkSingleSelectChipGroup';
+
+const defaultProps = {
+ type: CHIP_GROUP_TYPES.wrap,
+ ariaLabel: 'a11y label',
+}
+
+describe('BpkSingleSelectChipGroup', () => {
+ beforeEach(() => {
+ window.matchMedia = jest.fn().mockImplementation(() => ({
+ matches: true,
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ }));
+ });
+
+ const chips = [
+ {
+ text: 'London',
+ },
+ {
+ text: 'Berlin',
+ },
+ {
+ text: 'Florence',
+ },
+ {
+ text: 'Stockholm',
+ }
+ ];
+
+ it('should call onItemClick when a chip is clicked', async () => {
+ const user = userEvent.setup();
+
+ const onItemClick = jest.fn();
+
+ render(
+ ,
+ );
+
+ await user.click(screen.getByText('Berlin'));
+
+ expect(onItemClick).toHaveBeenCalledTimes(1);
+ expect(onItemClick).toHaveBeenCalledWith({ text: 'Berlin' }, true, 1);
+ });
+
+ it('Should use selectedIndex prop to determine selected chip', async () => {
+ const chipsWithSelected = [
+ {
+ text: 'London',
+ selected: true,
+ },
+ {
+ text: 'Berlin',
+ selected: false,
+ },
+ {
+ text: 'Florence',
+ selected: true,
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ expect(screen.getByRole('radio', { name: 'Berlin' })).toHaveClass('bpk-chip--default-selected')
+ expect(screen.getByRole('radio', { name: 'London' })).not.toHaveClass('bpk-chip--default-selected')
+ expect(screen.getByRole('radio', { name: 'Florence' })).not.toHaveClass('bpk-chip--default-selected')
+ });
+});
diff --git a/packages/bpk-component-chip-group/src/BpkSingleSelectChipGroup.tsx b/packages/bpk-component-chip-group/src/BpkSingleSelectChipGroup.tsx
new file mode 100644
index 0000000000..8a10b91d12
--- /dev/null
+++ b/packages/bpk-component-chip-group/src/BpkSingleSelectChipGroup.tsx
@@ -0,0 +1,41 @@
+/*
+ * Backpack - Skyscanner's Design System
+ *
+ * Copyright 2016 Skyscanner Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import BpkMultiSelectChipGroup, { type SingleSelectChipItem, type MultiSelectProps } from './BpkMultiSelectChipGroup';
+
+export type SingleSelectProps = {
+ chips: SingleSelectChipItem[];
+ onItemClick?: (item: SingleSelectChipItem, selected: boolean, index: number) => void,
+ selectedIndex?: number;
+} & MultiSelectProps;
+
+const BpkSingleSelectChipGroup = ({ chips, onItemClick, selectedIndex, ...rest }: SingleSelectProps) => {
+ const chipsWithSelection = chips.map((chip, index) => chip && ({
+ ...chip,
+ selected: index === selectedIndex,
+ onClick: (selected: boolean, clickedIndex: number) => {
+ if (onItemClick) {
+ onItemClick(chip, selected, clickedIndex);
+ }
+ },
+ }));
+
+ return ;
+};
+
+export default BpkSingleSelectChipGroup;
diff --git a/packages/bpk-component-chip-group/src/Nudger-test.tsx b/packages/bpk-component-chip-group/src/Nudger-test.tsx
new file mode 100644
index 0000000000..157f61d0fd
--- /dev/null
+++ b/packages/bpk-component-chip-group/src/Nudger-test.tsx
@@ -0,0 +1,84 @@
+/*
+ * Backpack - Skyscanner's Design System
+ *
+ * Copyright 2016 Skyscanner Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { MutableRefObject } from 'react';
+
+import { act, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { CHIP_TYPES } from '../../bpk-component-chip';
+
+import Nudger, { POSITION } from './Nudger';
+
+
+const mockIsRtl = jest.fn(() => false);
+
+jest.mock('../../bpk-react-utils/index', () => ({
+ ...jest.requireActual('../../bpk-react-utils/index'),
+ isRTL: () => mockIsRtl(),
+}));
+
+const createMockScrollContainerRef = (isRtl: boolean): MutableRefObject => ({
+ current: {
+ scrollBy: jest.fn() as (options?: any) => void,
+ offsetWidth: 100,
+ scrollLeft: isRtl ? -150 : 150,
+ scrollWidth: 500,
+ },
+} as MutableRefObject);
+
+describe('Nudger', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ jest.useFakeTimers();
+ });
+
+ it.each([
+ [POSITION.trailing, false],
+ [POSITION.leading, false],
+ [POSITION.trailing, true],
+ [POSITION.leading, true],
+ ])('should call scrollBy when leading=%s and isRtl=%s', async (position, isRtl) => {
+ const user = userEvent.setup({advanceTimers: jest.advanceTimersByTime});
+ const mockScrollContainerRef = createMockScrollContainerRef(isRtl);
+ mockIsRtl.mockReturnValue(isRtl);
+ render();
+ await waitFor(() => {
+ expect(screen.queryByRole('button')).not.toHaveAttribute('disabled');
+ });
+
+ await user.click(screen.getByRole('button'));
+
+ const leading = position === POSITION.leading;
+ const isLeft = (leading && !isRtl) || (!leading && isRtl);
+ expect(mockScrollContainerRef.current.scrollBy).toHaveBeenCalledTimes(1);
+ expect(mockScrollContainerRef.current.scrollBy).toHaveBeenCalledWith({
+ left: isLeft ? -150 : 150,
+ behavior: 'smooth',
+ });
+ });
+
+ it('should render button style matching chips', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.queryByRole('button')).toBeVisible();
+ });
+
+ expect(screen.getByRole('button')).toHaveClass('bpk-button--secondary-on-dark');
+ });
+});
diff --git a/packages/bpk-component-chip-group/src/Nudger.module.scss b/packages/bpk-component-chip-group/src/Nudger.module.scss
new file mode 100644
index 0000000000..8c6058191e
--- /dev/null
+++ b/packages/bpk-component-chip-group/src/Nudger.module.scss
@@ -0,0 +1,31 @@
+/*
+ * Backpack - Skyscanner's Design System
+ *
+ * Copyright 2016 Skyscanner Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@use '../../unstable__bpk-mixins/tokens';
+
+.bpk-chip-group-nudger {
+ &--leading {
+ margin-inline-end: tokens.bpk-spacing-md();
+ margin-inline-start: tokens.bpk-spacing-sm();
+ }
+
+ &--trailing {
+ margin-inline-end: tokens.bpk-spacing-sm();
+ margin-inline-start: tokens.bpk-spacing-md();
+ }
+}
diff --git a/packages/bpk-component-chip-group/src/Nudger.tsx b/packages/bpk-component-chip-group/src/Nudger.tsx
new file mode 100644
index 0000000000..969768e31f
--- /dev/null
+++ b/packages/bpk-component-chip-group/src/Nudger.tsx
@@ -0,0 +1,116 @@
+/*
+ * Backpack - Skyscanner's Design System
+ *
+ * Copyright 2016 Skyscanner Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { type MutableRefObject, useEffect, useState } from 'react';
+
+import { BpkButtonV2, BUTTON_TYPES } from '../../bpk-component-button';
+import { CHIP_TYPES } from '../../bpk-component-chip';
+import { withButtonAlignment } from '../../bpk-component-icon/index';
+import ArrowLeft from '../../bpk-component-icon/sm/long-arrow-left';
+import ArrowRight from '../../bpk-component-icon/sm/long-arrow-right';
+import { cssModules, isRTL } from '../../bpk-react-utils/index';
+
+import type { ChipStyleType } from './BpkMultiSelectChipGroup';
+
+import STYLES from './Nudger.module.scss';
+
+const getClassName = cssModules(STYLES);
+
+
+const CHIP_STYLE_TO_BUTTON_STYLE = {
+ [CHIP_TYPES.default]: BUTTON_TYPES.secondary,
+ [CHIP_TYPES.onDark]: BUTTON_TYPES.secondaryOnDark,
+ [CHIP_TYPES.onImage]: BUTTON_TYPES.primaryOnDark,
+}
+
+export const POSITION = {
+ leading: 'leading',
+ trailing: 'trailing',
+} as const;
+
+type Props = {
+ ariaLabel: string;
+ chipStyle?: ChipStyleType;
+ scrollContainerRef: MutableRefObject;
+ position: (typeof POSITION)[keyof typeof POSITION];
+}
+
+const AlignedLeftArrowIcon = withButtonAlignment(ArrowLeft);
+const AlignedRightArrowIcon = withButtonAlignment(ArrowRight);
+
+// Chosen based on feeling good with the example stories
+const SCROLL_DISTANCE = 150;
+
+const Nudger = ({
+ ariaLabel,
+ chipStyle = CHIP_TYPES.default,
+ position,
+ scrollContainerRef
+}: Props) => {
+ const [show, setShow] = useState(false);
+ const [enabled, setEnabled] = useState(false);
+
+ const leading = position === POSITION.leading;
+ const rtl = isRTL();
+ const isLeft = (leading && !rtl) || (!leading && rtl);
+
+ useEffect(() => {
+ const interval = setInterval(() => {
+ if (!scrollContainerRef.current) {
+ return;
+ }
+
+ const { offsetWidth, scrollLeft, scrollWidth } = scrollContainerRef.current;
+ const scrollValue = rtl ? -Math.floor(scrollLeft) : Math.ceil(scrollLeft);
+ const showLeading = scrollValue > 0;
+ const showTrailing = scrollValue < scrollWidth - offsetWidth;
+
+ setShow(showLeading || showTrailing);
+ setEnabled((leading && showLeading) || (!leading && showTrailing))
+ }, 100);
+ return () => clearInterval(interval);
+ }, [leading, rtl, scrollContainerRef]);
+
+ const classNames = getClassName(
+ 'bpk-chip-group-nudger',
+ `bpk-chip-group-nudger--${leading ? "leading" : "trailing"}`,
+ )
+
+ return show ? (
+
+
{
+ if (scrollContainerRef.current) {
+ scrollContainerRef.current.scrollBy({
+ left: isLeft ? -SCROLL_DISTANCE : SCROLL_DISTANCE,
+ behavior: 'smooth',
+ });
+ }
+ }}
+ >
+ {isLeft ? : }
+
+
+ ) : null;
+}
+
+export default Nudger;
diff --git a/packages/bpk-component-chip-group/src/accessibility-test.tsx b/packages/bpk-component-chip-group/src/accessibility-test.tsx
new file mode 100644
index 0000000000..1a1b06185a
--- /dev/null
+++ b/packages/bpk-component-chip-group/src/accessibility-test.tsx
@@ -0,0 +1,109 @@
+/*
+ * Backpack - Skyscanner's Design System
+ *
+ * Copyright 2016 Skyscanner Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { render } from '@testing-library/react';
+import { axe } from 'jest-axe';
+
+import BpkMultiSelectChipGroup, { CHIP_GROUP_TYPES } from './BpkMultiSelectChipGroup';
+import BpkSingleSelectChipGroup from './BpkSingleSelectChipGroup';
+
+const chips = [
+ {
+ text: 'London',
+ },
+ {
+ text: 'Berlin',
+ selected: true,
+ },
+ {
+ text: 'Florence',
+ },
+ {
+ text: 'Stockholm',
+ disabled: true,
+ }
+];
+
+describe('BpkMultiSelectChipGroup accessibility tests', () => {
+ beforeEach(() => {
+ window.matchMedia = jest.fn().mockImplementation(() => ({
+ matches: true,
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ }));
+ });
+
+ it('should not have programmatically-detectable accessibility issues when type = rail', async () => {
+ const { container } = render(
+
+ );
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+
+ it('should not have programmatically-detectable accessibility issues when type = wrap', async () => {
+ const { container } = render(
+
+ );
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+});
+
+describe('BpkSingleSelectChipGroup accessibility tests', () => {
+ it('should not have programmatically-detectable accessibility issues when type = rail', async () => {
+ const { container } = render(
+
+ );
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+
+ it('should not have programmatically-detectable accessibility issues when type = wrap', async () => {
+ const { container } = render(
+
+ );
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+});
diff --git a/scripts/jest/setup.js b/scripts/jest/setup.js
index d44c93dc9d..046ff222bb 100644
--- a/scripts/jest/setup.js
+++ b/scripts/jest/setup.js
@@ -17,6 +17,7 @@
*/
import 'jest-axe/extend-expect';
+import '@testing-library/jest-dom';
import 'raf/polyfill';
import registerRequireContextHook from 'babel-plugin-require-context-hook/register';