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, + }) => ( +
+ {ariaLabel && {ariaLabel}} + {label && {label}} + {chips.map((chip, index) => )} +
+); + +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';