diff --git a/.yarn/cache/@esbuild-darwin-arm64-npm-0.17.19-64d69299ed-8.zip b/.yarn/cache/@esbuild-darwin-arm64-npm-0.17.19-64d69299ed-8.zip new file mode 100644 index 0000000000..6a5cd07519 Binary files /dev/null and b/.yarn/cache/@esbuild-darwin-arm64-npm-0.17.19-64d69299ed-8.zip differ diff --git a/.yarn/cache/@swc-core-darwin-arm64-npm-1.3.61-8598d7162f-8.zip b/.yarn/cache/@swc-core-darwin-arm64-npm-1.3.61-8598d7162f-8.zip new file mode 100644 index 0000000000..7bfb6fe48d Binary files /dev/null and b/.yarn/cache/@swc-core-darwin-arm64-npm-1.3.61-8598d7162f-8.zip differ diff --git a/.yarn/cache/@tef-novum-webview-bridge-npm-3.27.0-c4c0055150-acbbb406bc.zip b/.yarn/cache/@tef-novum-webview-bridge-npm-3.27.0-c4c0055150-acbbb406bc.zip new file mode 100644 index 0000000000..a830b9d50c Binary files /dev/null and b/.yarn/cache/@tef-novum-webview-bridge-npm-3.27.0-c4c0055150-acbbb406bc.zip differ diff --git a/.yarn/cache/@tef-novum-webview-bridge-npm-3.8.0-896817490e-0438e76a70.zip b/.yarn/cache/@tef-novum-webview-bridge-npm-3.8.0-896817490e-0438e76a70.zip deleted file mode 100644 index 83ceb60a55..0000000000 Binary files a/.yarn/cache/@tef-novum-webview-bridge-npm-3.8.0-896817490e-0438e76a70.zip and /dev/null differ diff --git a/.yarn/cache/@telefonica-eslint-config-npm-1.6.0-26975edce8-4a4d122daf.zip b/.yarn/cache/@telefonica-eslint-config-npm-1.7.0-590313811e-94619bec66.zip similarity index 55% rename from .yarn/cache/@telefonica-eslint-config-npm-1.6.0-26975edce8-4a4d122daf.zip rename to .yarn/cache/@telefonica-eslint-config-npm-1.7.0-590313811e-94619bec66.zip index 1cfb2f7c24..673c04d390 100644 Binary files a/.yarn/cache/@telefonica-eslint-config-npm-1.6.0-26975edce8-4a4d122daf.zip and b/.yarn/cache/@telefonica-eslint-config-npm-1.7.0-590313811e-94619bec66.zip differ diff --git a/doc/sheet.md b/doc/sheet.md new file mode 100644 index 0000000000..5ed178c21b --- /dev/null +++ b/doc/sheet.md @@ -0,0 +1,133 @@ +# Sheet + +Mística provides a sheet component that can be used to display a modal-like content from over the main content +of the screen. + +## Basic usage + +You can show any content you want inside the sheet by passing it as a child of the component. + +```jsx +import {Sheet} from 'mistica'; + +const MyComponent = () => { + const [showSheet, setShowSheet] = useState(false); + return ( + <> + setShowSheet(true)}>show sheet + {showSheet && ( + setShowSheet(false)}> + + + )} + > + ); +}; +``` + +The sheet will close when the user does the swipe down gesture or when the background overlay is touched. The +`onClose` callback is called when the closing animation finishes, that's the right place to unmount the sheet +as shown in the example above. + +You can also close the sheet programmatically using the render prop: + +```jsx +import {Sheet} from 'mistica'; + +const MyComponent = () => { + const [showSheet, setShowSheet] = useState(false); + return ( + <> + setShowSheet(true)}>show sheet + {showSheet && ( + setShowSheet(false)}> + {({closeModal, modalTitleId}) => ( + <> + My sheet + + Close + > + )} + + )} + > + ); +}; +``` + +## Sheet with predefined content + +Mística predefines some common sheet patterns for you to use: `RadioListSheet`, `ActionsListSheet`, +`InfoSheet` and `ActionsSheet`. You can see examples in the storybook. + +## `showSheet` imperative api + +Instead of using React components, there is an alternative way to show a sheet: using the `showSheet` +function. For this to work, you need to render a `` somewhere in your app, typically where you +render the mistica ``, but it could be anywhere. + +```jsx +import {SheetRoot} from '@telefonica/mistica'; + +export const App = () => { + return ( + <> + + + > + ); +}; +``` + +Then you can call `showSheet` from anywhere: + +```jsx +import {showSheet} from '@telefonica/mistica'; + +const MyComponent = () => { + return ( + + showSheet({ + type: 'RADIO_LIST', + props: { + title: 'Select an fruit', + items: [ + {id: '1', title: 'Apple'}, + {id: '2', title: 'Banana'}, + {id: '3', title: 'Orange'}, + ], + }, + }).then((result) => { + // The promise is resolved when the sheet is closed + console.log(result); + }) + } + > + show sheet + + ); +}; +``` + +### Native implementation + +If you are using mistica inside Novum app, you can configure `showSheet` to use the native sheet +implementation with the webview bridge. + +```jsx +import {SheetRoot} from '@telefonica/mistica'; +import {bottomSheet, isWebViewBridgeAvailable} from '@tef-novum/webview-bridge'; + +export const App = () => { + return ( + <> + + + > + ); +}; +``` + +Then when you call `showSheet`, if the code is running inside a webview, it will use the native implementation +instead of the web one. diff --git a/package.json b/package.json index da7b10b7eb..f4a418479f 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "@swc/core": "^1.3.61", "@swc/jest": "^0.2.26", "@telefonica/acceptance-testing": "2.13.0", - "@telefonica/eslint-config": "^1.6.0", + "@telefonica/eslint-config": "^1.7.0", "@telefonica/prettier-config": "^1.1.0", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", @@ -143,7 +143,7 @@ }, "dependencies": { "@juggle/resize-observer": "^3.3.1", - "@tef-novum/webview-bridge": "^3.8.0", + "@tef-novum/webview-bridge": "^3.27.0", "@telefonica/libphonenumber": "^2.8.1", "@vanilla-extract/css": "^1.9.5", "@vanilla-extract/dynamic": "^2.0.3", diff --git a/playroom/frame-component.tsx b/playroom/frame-component.tsx index 91ba14c208..e814d2c47c 100644 --- a/playroom/frame-component.tsx +++ b/playroom/frame-component.tsx @@ -6,6 +6,7 @@ import '../css/reset.css'; import * as React from 'react'; import { ThemeContextProvider, + SheetRoot, useModalState, OverscrollColorProvider, skinVars, @@ -64,6 +65,7 @@ const FrameComponent = ({children, theme}: Props): React.ReactNode => ( {(overridenTheme) => ( + {children} diff --git a/playroom/snippets.tsx b/playroom/snippets.tsx index 21c837ac5b..2797ed0542 100644 --- a/playroom/snippets.tsx +++ b/playroom/snippets.tsx @@ -1592,6 +1592,323 @@ const alertSnippets = [ `, }, + { + group: 'Modals', + name: 'showSheet (info)', + code: ` + { + showSheet({ + type: "INFO", + props: { + title: "Title", + subtitle: "Subtitle", + description: "Description", + items: [ + { id: "1", title: "Item 1", icon: { type: "bullet" } }, + { id: "2", title: "Item 2", icon: { type: "bullet" } }, + ], + }, + }); + }} +> + Open sheet +`, + }, + { + group: 'Modals', + name: 'showSheet (actions list)', + code: ` + { + showSheet({ + type: "ACTIONS_LIST", + props: { + title: "Title", + subtitle: "Subtitle", + description: "Description", + items: [ + { + id: "1", + title: "Action 1", + icon: { + url: "https://source.unsplash.com/600x600/?face", + }, + }, + { + id: "2", + title: "Destructive", + style: "destructive", + }, + ], + }, + }); + }} +> + Open sheet +`, + }, + { + group: 'Modals', + name: 'showSheet (actions)', + code: ` + { + showSheet({ + type: "ACTIONS", + props: { + title: "Title", + subtitle: "Subtitle", + description: "Description", + button: { + text: "Button", + }, + link: { + text: "Link", + withChevron: true, + }, + }, + }); + }} +> + Open sheet +`, + }, + { + group: 'Modals', + name: 'showSheet (radio list)', + code: ` + { + showSheet({ + type: "RADIO_LIST", + props: { + title: "Title", + subtitle: "Subtitle", + description: "Description", + selectedId: "1", + items: [ + { + id: "1", + title: "Item 1", + description: "Description", + icon: { + url: "https://source.unsplash.com/600x600/?face", + }, + }, + { + id: "2", + title: "Item 2", + description: "Description", + icon: { + url: "unknownurl", + }, + }, + ], + }, + }); + }} +> + Open sheet +`, + }, + { + group: 'Modals', + name: 'Sheet', + code: ` + { + setState("isSheetOpen", true); + }} +> + Open + + +{getState("isSheetOpen") && ( + { + setState("isSheetOpen", false); + }} + > + + + + + + +)}`, + }, + { + group: 'Modals', + name: 'InfoSheet', + code: ` + { + setState("isSheetOpen", true); + }} +> + Open + + +{getState("isSheetOpen") && ( + { + setState("isSheetOpen", false); + }} + title="Title" + subtitle="Subtitle" + description="Description" + items={[ + { + id: "1", + title: "Item 1", + description: "Description", + icon: { type: "bullet" }, + }, + { + id: "2", + title: "Item 2", + description: "Description", + icon: { type: "regular", Icon: IconCocktailRegular }, + }, + { + id: "3", + title: "Item 3", + description: "Description", + icon: { type: "small", Icon: IconCheckRegular }, + }, + ]} + /> +)}`, + }, + { + group: 'Modals', + name: 'RadioListSheet', + code: ` + { + setState("isSheetOpen", true); + }} +> + Open + + +{getState("isSheetOpen") && ( + { + setState("isSheetOpen", false); + }} + onSelect={(selected) => console.log(selected)} + title="Title" + subtitle="Subtitle" + description="Description" + items={[ + "Apple", + "Banana", + "Pineapple", + "Mango", + "Peach", + "Pear", + "Strawberry", + "Watermelon", + "Kiwi", + "Cherry", + "Grape", + "Lemon", + "Lime", + ].map((fruit, idx) => ({ + id: String(idx), + title: fruit, + description: "Description", + asset: ( + + + + ), + }))} + /> +)}`, + }, + { + group: 'Modals', + name: 'ActionsListSheet', + code: ` + { + setState("isSheetOpen", true); + }} +> + Open + + +{getState("isSheetOpen") && ( + { + setState("isSheetOpen", false); + }} + onSelect={(selected) => console.log(selected)} + title="Title" + subtitle="Subtitle" + description="Description" + items={[ + { + id: "1", + title: "Action with icon", + icon: { + Icon: IconLightningRegular, + }, + }, + { + id: "2", + title: "Action without icon", + }, + { + id: "3", + title: "Destructive action", + style: "destructive", + icon: { + Icon: IconTrashCanRegular, + }, + }, + ]} + /> +)}`, + }, + { + group: 'Modals', + name: 'ActionsSheet', + code: ` + { + setState("isSheetOpen", true); + }} +> + Open + + +{getState("isSheetOpen") && ( + { + setState("isSheetOpen", false); + }} + onPressButton={(selected) => console.log(selected)} + title="Title" + subtitle="Subtitle" + description="Description" + button={{ text: "Primary" }} + secondaryButton={{ text: "Secondary" }} + buttonLink={{ text: "Link", withChevron: true }} + /> +)}`, + }, ]; const skeletonSnippets = [ diff --git a/src/__screenshot_tests__/__image_snapshots__/sheet-screenshot-test-tsx-actions-list-sheet-in-desktop-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/sheet-screenshot-test-tsx-actions-list-sheet-in-desktop-1-snap.png new file mode 100644 index 0000000000..446cd58b71 Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/sheet-screenshot-test-tsx-actions-list-sheet-in-desktop-1-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/sheet-screenshot-test-tsx-actions-list-sheet-in-mobile-ios-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/sheet-screenshot-test-tsx-actions-list-sheet-in-mobile-ios-1-snap.png new file mode 100644 index 0000000000..aca17aa018 Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/sheet-screenshot-test-tsx-actions-list-sheet-in-mobile-ios-1-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/sheet-screenshot-test-tsx-actions-sheet-in-desktop-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/sheet-screenshot-test-tsx-actions-sheet-in-desktop-1-snap.png new file mode 100644 index 0000000000..e5cc69454a Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/sheet-screenshot-test-tsx-actions-sheet-in-desktop-1-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/sheet-screenshot-test-tsx-actions-sheet-in-mobile-ios-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/sheet-screenshot-test-tsx-actions-sheet-in-mobile-ios-1-snap.png new file mode 100644 index 0000000000..83e728bc3a Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/sheet-screenshot-test-tsx-actions-sheet-in-mobile-ios-1-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/sheet-screenshot-test-tsx-info-sheet-in-desktop-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/sheet-screenshot-test-tsx-info-sheet-in-desktop-1-snap.png new file mode 100644 index 0000000000..c564778e43 Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/sheet-screenshot-test-tsx-info-sheet-in-desktop-1-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/sheet-screenshot-test-tsx-info-sheet-in-mobile-ios-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/sheet-screenshot-test-tsx-info-sheet-in-mobile-ios-1-snap.png new file mode 100644 index 0000000000..f37bc412e8 Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/sheet-screenshot-test-tsx-info-sheet-in-mobile-ios-1-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/sheet-screenshot-test-tsx-radio-list-sheet-in-desktop-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/sheet-screenshot-test-tsx-radio-list-sheet-in-desktop-1-snap.png new file mode 100644 index 0000000000..3a1c8ec119 Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/sheet-screenshot-test-tsx-radio-list-sheet-in-desktop-1-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/sheet-screenshot-test-tsx-radio-list-sheet-in-mobile-ios-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/sheet-screenshot-test-tsx-radio-list-sheet-in-mobile-ios-1-snap.png new file mode 100644 index 0000000000..f42e67d87d Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/sheet-screenshot-test-tsx-radio-list-sheet-in-mobile-ios-1-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/sheet-screenshot-test-tsx-sheet-in-desktop-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/sheet-screenshot-test-tsx-sheet-in-desktop-1-snap.png new file mode 100644 index 0000000000..bea2c1f047 Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/sheet-screenshot-test-tsx-sheet-in-desktop-1-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/sheet-screenshot-test-tsx-sheet-in-mobile-ios-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/sheet-screenshot-test-tsx-sheet-in-mobile-ios-1-snap.png new file mode 100644 index 0000000000..7649c40994 Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/sheet-screenshot-test-tsx-sheet-in-mobile-ios-1-snap.png differ diff --git a/src/__screenshot_tests__/sheet-screenshot-test.tsx b/src/__screenshot_tests__/sheet-screenshot-test.tsx new file mode 100644 index 0000000000..e39a2bcf94 --- /dev/null +++ b/src/__screenshot_tests__/sheet-screenshot-test.tsx @@ -0,0 +1,83 @@ +import {openStoryPage, screen} from '../test-utils'; + +const TESTABLE_DEVICES = ['MOBILE_IOS', 'DESKTOP'] as const; + +test.each(TESTABLE_DEVICES)('Sheet in %s', async (device) => { + const page = await openStoryPage({ + id: 'components-modals-sheet--default', + device, + }); + + const button = await screen.findByRole('button', {name: 'Open'}); + await button.click(); + + await screen.findByRole('dialog'); + + const image = await page.screenshot(); + + expect(image).toMatchImageSnapshot(); +}); + +test.each(TESTABLE_DEVICES)('ActionsListSheet in %s', async (device) => { + const page = await openStoryPage({ + id: 'components-modals-sheet--actions-list', + device, + }); + + const button = await screen.findByRole('button', {name: 'Open'}); + await button.click(); + + await screen.findByRole('dialog'); + + const image = await page.screenshot(); + + expect(image).toMatchImageSnapshot(); +}); + +test.each(TESTABLE_DEVICES)('RadioListSheet in %s', async (device) => { + const page = await openStoryPage({ + id: 'components-modals-sheet--radio-list', + device, + }); + + const button = await screen.findByRole('button', {name: 'Open'}); + await button.click(); + + await screen.findByRole('dialog'); + + const image = await page.screenshot(); + + expect(image).toMatchImageSnapshot(); +}); + +test.each(TESTABLE_DEVICES)('InfoSheet in %s', async (device) => { + const page = await openStoryPage({ + id: 'components-modals-sheet--info', + device, + }); + + const button = await screen.findByRole('button', {name: 'Open'}); + await button.click(); + + await screen.findByRole('dialog'); + + const image = await page.screenshot(); + + expect(image).toMatchImageSnapshot(); +}); + +test.each(TESTABLE_DEVICES)('ActionsSheet in %s', async (device) => { + const page = await openStoryPage({ + id: 'components-modals-sheet--actions', + device, + }); + + const button = await screen.findByRole('button', {name: 'Open'}); + await button.click(); + + await screen.findByRole('dialog'); + + const image = await page.screenshot(); + + expect(image).toMatchImageSnapshot(); +}); diff --git a/src/__stories__/sheet-story.tsx b/src/__stories__/sheet-story.tsx new file mode 100644 index 0000000000..10241b122e --- /dev/null +++ b/src/__stories__/sheet-story.tsx @@ -0,0 +1,535 @@ +import * as React from 'react'; +import { + Sheet, + SheetRoot, + Box, + ButtonPrimary, + Circle, + IconCheckRegular, + IconCocktailRegular, + IconLightningRegular, + IconMobileDeviceRegular, + IconTrashCanRegular, + Inline, + Placeholder, + ResponsiveLayout, + showSheet, + skinVars, + Stack, + Text2, + Text3, + Title1, + Callout, + IconInformationRegular, + ButtonLink, +} from '..'; +import {ActionsSheet, ActionsListSheet, InfoSheet, RadioListSheet} from '../sheet'; +import avatarImg from './images/avatar.jpg'; + +export default { + title: 'Components/Modals/Sheet', + component: Sheet, + parameters: { + fullScreen: true, + }, +}; + +export const Default: StoryComponent = () => { + const [open, setOpen] = React.useState(false); + + return ( + + + { + setOpen(true); + }} + > + Open + + } + buttonLink={ + + See docs + + } + /> + + + {open && ( + { + setOpen(false); + }} + > + + + + + + + )} + + ); +}; + +Default.storyName = 'Sheet'; + +type SheetArgs = { + title: string; + subtitle: string; + description: string; +}; + +type RadioListSheetArgs = SheetArgs & { + selectedId: string; +}; + +export const RadioList: StoryComponent = ({title, subtitle, description, selectedId}) => { + const [open, setOpen] = React.useState(false); + const [selected, setSelected] = React.useState(null); + + return ( + + + { + setOpen(true); + setSelected(null); + }} + > + Open + + {selected && ( + + selectedId: {selected} + + )} + + + {open && ( + { + setOpen(false); + }} + onSelect={(item) => { + setSelected(item); + }} + title={title} + subtitle={subtitle} + description={description} + selectedId={selectedId === 'none' ? undefined : selectedId} + items={[ + 'Apple', + 'Banana', + 'Pineapple', + 'Mango', + 'Peach', + 'Pear', + 'Strawberry', + 'Watermelon', + 'Kiwi', + 'Cherry', + 'Grape', + 'Lemon', + 'Lime', + ].map((fruit, idx) => ({ + id: String(idx), + title: fruit, + description: 'Description', + asset: ( + + + + ), + }))} + /> + )} + + ); +}; + +RadioList.storyName = 'RadioListSheet'; +RadioList.args = { + title: 'Select a fruit', + subtitle: 'Subtitle', + description: 'Description', + selectedId: 'none', +}; +RadioList.argTypes = { + selectedId: { + control: {type: 'select'}, + options: ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', 'none'], + }, +}; + +export const ActionsList: StoryComponent = ({title, subtitle, description}) => { + const [open, setOpen] = React.useState(false); + const [selected, setSelected] = React.useState(null); + + return ( + + + { + setOpen(true); + setSelected(null); + }} + > + Open + + {selected && ( + + selectedId: {selected} + + )} + + + {open && ( + { + setOpen(false); + }} + onSelect={(item) => { + setSelected(item); + }} + title={title} + subtitle={subtitle} + description={description} + items={[ + { + id: '1', + title: 'Action with icon', + icon: { + Icon: IconLightningRegular, + }, + }, + { + id: '2', + title: 'Action without icon', + }, + { + id: '3', + title: 'Destructive action', + style: 'destructive', + icon: { + Icon: IconTrashCanRegular, + }, + }, + ]} + /> + )} + + ); +}; + +ActionsList.storyName = 'ActionsListSheet'; +ActionsList.args = { + title: 'Title', + subtitle: 'Subtitle', + description: 'Description', +}; + +type InfoSheetArgs = SheetArgs & { + numItems: number; + iconType: 'bullet' | 'regular' | 'small'; +}; + +export const Info: StoryComponent = ({title, subtitle, description, numItems, iconType}) => { + const [open, setOpen] = React.useState(false); + + return ( + + { + setOpen(true); + }} + > + Open + + + {open && ( + { + setOpen(false); + }} + title={title} + subtitle={subtitle} + description={description} + items={Array.from({length: numItems}, (_, idx) => ({ + id: String(idx), + title: 'Item ' + idx, + description: 'Description', + icon: + iconType === 'bullet' + ? {type: 'bullet'} + : { + type: iconType, + Icon: { + regular: IconCocktailRegular, + small: IconCheckRegular, + }[iconType], + }, + }))} + /> + )} + + ); +}; + +Info.storyName = 'InfoSheet'; +Info.args = { + title: 'Title', + subtitle: 'Subtitle', + description: 'Description', + numItems: 5, + iconType: 'bullet', +}; +Info.argTypes = { + iconType: { + control: {type: 'select'}, + options: ['bullet', 'regular', 'small'], + }, +}; + +type ActionsSheetArgs = SheetArgs & { + buttonText: string; + secondaryButtonText: string; + buttonLinkText: string; + withChevron: boolean; +}; + +export const Actions: StoryComponent = ({ + title, + subtitle, + description, + buttonText, + secondaryButtonText, + buttonLinkText, + withChevron, +}) => { + const [open, setOpen] = React.useState(false); + const [pressedButton, setPressedButton] = React.useState(null); + + return ( + + + { + setOpen(true); + setPressedButton(null); + }} + > + Open + + {pressedButton && ( + + pressedButton: {pressedButton} + + )} + + + {open && ( + { + setOpen(false); + }} + onPressButton={setPressedButton} + title={title} + subtitle={subtitle} + description={description} + button={{ + text: buttonText, + }} + secondaryButton={ + secondaryButtonText + ? { + text: secondaryButtonText, + } + : undefined + } + buttonLink={ + buttonLinkText + ? { + text: buttonLinkText, + withChevron, + } + : undefined + } + /> + )} + + ); +}; + +Actions.storyName = 'ActionsSheet'; +Actions.args = { + title: 'Title', + subtitle: 'Subtitle', + description: 'Description', + buttonText: 'Button', + secondaryButtonText: 'Secondary button', + buttonLinkText: 'Link', + withChevron: false, +}; +Actions.argTypes = { + withChevron: { + control: {type: 'boolean'}, + if: {arg: 'buttonLinkText'}, + }, +}; + +type RootArgs = { + title: string; + subtitle: string; + description: string; +}; + +export const Root: StoryComponent = ({title, subtitle, description}) => { + const [response, setResponse] = React.useState(); + return ( + + + + + { + setResponse(undefined); + showSheet({ + type: 'INFO', + props: { + title, + subtitle, + description, + items: [ + { + id: '1', + title: 'Item 1', + description: 'Description', + icon: { + type: 'bullet', + }, + }, + { + id: '2', + title: 'Item 2', + description: 'Description', + icon: { + type: 'bullet', + }, + }, + ], + }, + }).then(setResponse); + }} + > + 'INFO' + + { + setResponse(undefined); + showSheet({ + type: 'ACTIONS_LIST', + props: { + title, + subtitle, + description, + items: [ + { + id: '1', + title: 'Action 1', + icon: { + url: avatarImg, + }, + }, + { + id: '2', + title: 'Destructive', + style: 'destructive', + }, + ], + }, + }).then(setResponse); + }} + > + 'ACTIONS_LIST' + + { + setResponse(undefined); + showSheet({ + type: 'ACTIONS', + props: { + title, + subtitle, + description, + button: { + text: 'Button', + }, + link: { + text: 'Link', + withChevron: true, + }, + }, + }).then(setResponse); + }} + > + 'ACTIONS' + + { + setResponse(undefined); + showSheet({ + type: 'RADIO_LIST', + props: { + title, + subtitle, + description, + selectedId: '1', + items: [ + { + id: '1', + title: 'Item 1', + description: 'Description', + icon: { + url: avatarImg, + }, + }, + { + id: '2', + title: 'Item 2', + description: 'Description', + icon: { + url: 'unknownurl', + }, + }, + ], + }, + }).then(setResponse); + }} + > + 'RADIO_LIST' + + + Response: + + {JSON.stringify(response, null, 2)} + + + + ); +}; + +Root.storyName = 'SheetRoot'; +Root.args = { + title: 'Title', + subtitle: 'Subtitle', + description: 'Description', +}; diff --git a/src/__tests__/sheet-test.tsx b/src/__tests__/sheet-test.tsx new file mode 100644 index 0000000000..33f618e29b --- /dev/null +++ b/src/__tests__/sheet-test.tsx @@ -0,0 +1,700 @@ +import * as React from 'react'; +import Sheet, {ActionsSheet, ActionsListSheet, InfoSheet, RadioListSheet} from '../sheet'; +import {act, render, screen, waitForElementToBeRemoved, within} from '@testing-library/react'; +import {SheetRoot, ButtonPrimary, showSheet, ThemeContextProvider, Title1} from '..'; +import {makeTheme} from './test-utils'; +import userEvent from '@testing-library/user-event'; + +test('Sheet', async () => { + const TestComponent = () => { + const [showModal, setShowModal] = React.useState(false); + return ( + <> + setShowModal(true)}>Open + {showModal && ( + { + setShowModal(false); + }} + > + {({closeModal, modalTitleId}) => ( + <> + Sheet title + Close + > + )} + + )} + > + ); + }; + + render( + + + + ); + + const openButton = screen.getByRole('button', {name: 'Open'}); + await userEvent.click(openButton); + const sheet = await screen.findByRole('dialog', {name: 'Sheet title'}); + + expect(sheet).toBeInTheDocument(); + + const closeButton = await within(sheet).findByRole('button', {name: 'Close'}); + + await userEvent.click(closeButton); + await waitForElementToBeRemoved(sheet); +}); + +test('RadioListSheet', async () => { + const selectSpy = jest.fn(); + + const TestComponent = () => { + const [showModal, setShowModal] = React.useState(false); + return ( + <> + setShowModal(true)}>Open + {showModal && ( + { + setShowModal(false); + }} + selectedId="2" + items={[ + {id: '1', title: 'Item 1'}, + {id: '2', title: 'Item 2'}, + ]} + /> + )} + > + ); + }; + + render( + + + + ); + + const openButton = screen.getByRole('button', {name: 'Open'}); + await userEvent.click(openButton); + const sheet = await screen.findByRole('dialog', {name: 'Choose an item'}); + + expect(sheet).toBeInTheDocument(); + + const item1 = await within(sheet).findByRole('radio', {name: 'Item 1'}); + const item2 = await within(sheet).findByRole('radio', {name: 'Item 2'}); + const continueButton = await within(sheet).findByRole('button', {name: 'Continuar'}); + expect(item2).toBeChecked(); + + await userEvent.click(item1); + await userEvent.click(continueButton); + + await waitForElementToBeRemoved(sheet); + expect(selectSpy).toHaveBeenCalledWith('1'); +}); + +test('ActionsListSheet', async () => { + const selectSpy = jest.fn(); + + const TestComponent = () => { + const [showModal, setShowModal] = React.useState(false); + return ( + <> + setShowModal(true)}>Open + {showModal && ( + { + setShowModal(false); + }} + items={[ + {id: '1', title: 'Action 1'}, + {id: '2', title: 'Action 2'}, + ]} + /> + )} + > + ); + }; + + render( + + + + ); + + const openButton = screen.getByRole('button', {name: 'Open'}); + await userEvent.click(openButton); + const sheet = await screen.findByRole('dialog', {name: 'Choose an action'}); + + expect(sheet).toBeInTheDocument(); + + const action1 = await within(sheet).findByRole('button', {name: 'Action 1'}); + const action2 = await within(sheet).findByRole('button', {name: 'Action 2'}); + + expect(action1).toBeInTheDocument(); + expect(action2).toBeInTheDocument(); + + await userEvent.click(action1); + + await waitForElementToBeRemoved(sheet); + expect(selectSpy).toHaveBeenCalledWith('1'); +}); + +test('InfoSheet', async () => { + const TestComponent = () => { + const [showModal, setShowModal] = React.useState(false); + return ( + <> + setShowModal(true)}>Open + {showModal && ( + + )} + > + ); + }; + + render( + + + + ); + + const openButton = screen.getByRole('button', {name: 'Open'}); + await userEvent.click(openButton); + const sheet = await screen.findByRole('dialog', {name: 'Title'}); + + expect(sheet).toBeInTheDocument(); + + const title = await within(sheet).findByRole('heading', {name: 'Title'}); + expect(title).toBeInTheDocument(); + + const subtitle = await within(sheet).findByText('Subtitle'); + expect(subtitle).toBeInTheDocument(); + + const description = await within(sheet).findByText('Description'); + expect(description).toBeInTheDocument(); + + const itemList = await within(sheet).findByRole('list'); + expect(itemList).toBeInTheDocument(); + + const items = await within(itemList).findAllByRole('listitem'); + expect(items).toHaveLength(2); +}); + +test('ActionsSheet', async () => { + const onPressButtonSpy = jest.fn(); + + const TestComponent = () => { + const [showModal, setShowModal] = React.useState(false); + return ( + <> + setShowModal(true)}>Open + {showModal && ( + { + setShowModal(false); + }} + /> + )} + > + ); + }; + + render( + + + + ); + + const openDialog = async () => { + const openButton = screen.getByRole('button', {name: 'Open'}); + await userEvent.click(openButton); + return await screen.findByRole('dialog', {name: 'Title'}); + }; + + const sheet = await openDialog(); + + expect(sheet).toBeInTheDocument(); + + const primary = await within(sheet).findByRole('button', {name: 'Button'}); + const secondary = await within(sheet).findByRole('button', {name: 'Secondary button'}); + const link = await within(sheet).findByRole('button', {name: 'Button link'}); + + expect(primary).toBeInTheDocument(); + expect(secondary).toBeInTheDocument(); + expect(link).toBeInTheDocument(); + + await userEvent.click(secondary); + + await waitForElementToBeRemoved(sheet); + expect(onPressButtonSpy).toHaveBeenCalledWith('SECONDARY'); +}); + +test('showSheet INFO', async () => { + const resultSpy = jest.fn(); + render( + + + + ); + + act(() => { + showSheet({ + type: 'INFO', + props: { + title: 'Title', + items: [{id: '1', title: 'Item 1', icon: {type: 'bullet'}}], + }, + }).then(resultSpy); + }); + + const sheet = await screen.findByRole('dialog', {name: 'Title'}); + expect(sheet).toBeInTheDocument(); + + const closeButton = await screen.findByRole('button', {name: 'Cerrar'}); + await userEvent.click(closeButton); + + await waitForElementToBeRemoved(sheet); + expect(resultSpy).toHaveBeenCalledWith(undefined); +}); + +test('showSheet ACTIONS_LIST', async () => { + const resultSpy = jest.fn(); + render( + + + + ); + + act(() => { + showSheet({ + type: 'ACTIONS_LIST', + props: { + title: 'Title', + items: [ + {id: '1', title: 'Item 1'}, + {id: '2', title: 'Item 2'}, + ], + }, + }).then(resultSpy); + }); + + const sheet = await screen.findByRole('dialog', {name: 'Title'}); + expect(sheet).toBeInTheDocument(); + + const item1 = await screen.findByRole('button', {name: 'Item 2'}); + + await userEvent.click(item1); + + await waitForElementToBeRemoved(sheet); + expect(resultSpy).toHaveBeenCalledWith({action: 'SUBMIT', selectedId: '2'}); +}); + +test('showSheet ACTIONS_LIST dismiss', async () => { + const resultSpy = jest.fn(); + render( + + + + ); + + act(() => { + showSheet({ + type: 'ACTIONS_LIST', + props: { + title: 'Title', + items: [ + {id: '1', title: 'Item 1'}, + {id: '2', title: 'Item 2'}, + ], + }, + }).then(resultSpy); + }); + + const sheet = await screen.findByRole('dialog', {name: 'Title'}); + expect(sheet).toBeInTheDocument(); + + const closeButton = await screen.findByRole('button', {name: 'Cerrar'}); + await userEvent.click(closeButton); + + await waitForElementToBeRemoved(sheet); + expect(resultSpy).toHaveBeenCalledWith({action: 'DISMISS'}); +}); + +test('showSheet RADIO_LIST', async () => { + const resultSpy = jest.fn(); + render( + + + + ); + + act(() => { + showSheet({ + type: 'RADIO_LIST', + props: { + title: 'Title', + items: [ + {id: '1', title: 'Item 1'}, + {id: '2', title: 'Item 2'}, + ], + }, + }).then(resultSpy); + }); + + const sheet = await screen.findByRole('dialog', {name: 'Title'}); + expect(sheet).toBeInTheDocument(); + + const item1 = await screen.findByRole('radio', {name: 'Item 2'}); + const continueButton = await screen.findByRole('button', {name: 'Continuar'}); + + await userEvent.click(item1); + await userEvent.click(continueButton); + + await waitForElementToBeRemoved(sheet); + expect(resultSpy).toHaveBeenCalledWith({action: 'SUBMIT', selectedId: '2'}); +}); + +test('showSheet RADIO_LIST dismiss', async () => { + const resultSpy = jest.fn(); + render( + + + + ); + + act(() => { + showSheet({ + type: 'RADIO_LIST', + props: { + title: 'Title', + items: [ + {id: '1', title: 'Item 1'}, + {id: '2', title: 'Item 2'}, + ], + }, + }).then(resultSpy); + }); + + const sheet = await screen.findByRole('dialog', {name: 'Title'}); + expect(sheet).toBeInTheDocument(); + + const closeButton = await screen.findByRole('button', {name: 'Cerrar'}); + await userEvent.click(closeButton); + + await waitForElementToBeRemoved(sheet); + expect(resultSpy).toHaveBeenCalledWith({action: 'DISMISS'}); +}); + +test('showSheet ACTIONS', async () => { + const resultSpy = jest.fn(); + render( + + + + ); + + act(() => { + showSheet({ + type: 'ACTIONS', + props: { + title: 'Title', + subtitle: 'Subtitle', + description: 'Description', + button: {text: 'Button'}, + secondaryButton: {text: 'Secondary button'}, + link: {text: 'Button link', withChevron: true}, + }, + }).then(resultSpy); + }); + + const sheet = await screen.findByRole('dialog', {name: 'Title'}); + expect(sheet).toBeInTheDocument(); + + const primary = await screen.findByRole('button', {name: 'Button'}); + const secondary = await screen.findByRole('button', {name: 'Secondary button'}); + const link = await screen.findByRole('button', {name: 'Button link'}); + + expect(primary).toBeInTheDocument(); + expect(secondary).toBeInTheDocument(); + expect(link).toBeInTheDocument(); + + await userEvent.click(link); + + await waitForElementToBeRemoved(sheet); + expect(resultSpy).toHaveBeenCalledWith({action: 'LINK'}); +}); + +test('showSheet ACTIONS dismiss', async () => { + const resultSpy = jest.fn(); + render( + + + + ); + + act(() => { + showSheet({ + type: 'ACTIONS', + props: { + title: 'Title', + subtitle: 'Subtitle', + description: 'Description', + button: {text: 'Button'}, + secondaryButton: {text: 'Secondary button'}, + link: {text: 'Button link', withChevron: true}, + }, + }).then(resultSpy); + }); + + const sheet = await screen.findByRole('dialog', {name: 'Title'}); + expect(sheet).toBeInTheDocument(); + + const closeButton = await screen.findByRole('button', {name: 'Cerrar'}); + await userEvent.click(closeButton); + + await waitForElementToBeRemoved(sheet); + expect(resultSpy).toHaveBeenCalledWith({action: 'DISMISS'}); +}); + +test('showSheet fails if SheetRoot is not rendered', async () => { + await expect( + showSheet({ + type: 'INFO', + props: { + title: 'Title', + items: [{id: '1', title: 'Item 1', icon: {type: 'bullet'}}], + }, + }) + ).rejects.toThrow('Tried to show a Sheet but the SheetRoot component was not mounted'); +}); + +test('showSheet fails if there is already a sheet open', async () => { + render( + + + + ); + + act(() => { + showSheet({ + type: 'INFO', + props: { + title: 'Title', + items: [{id: '1', title: 'Item 1', icon: {type: 'bullet'}}], + }, + }); + }); + + await expect( + showSheet({ + type: 'INFO', + props: { + title: 'Title', + items: [{id: '1', title: 'Item 1', icon: {type: 'bullet'}}], + }, + }) + ).rejects.toThrow('Tried to show a Sheet but there is already one open'); +}); + +test('showSheet with native implementation INFO', async () => { + const resultSpy = jest.fn(); + + const nativeImplementation = jest.fn(); + render( + + + + ); + + await act(() => + showSheet({ + type: 'INFO', + props: { + title: 'Title', + items: [{id: '1', title: 'Item 1', icon: {type: 'bullet'}}], + }, + }).then(resultSpy) + ); + + expect(nativeImplementation).toHaveBeenCalledWith({ + title: 'Title', + content: [ + { + type: 'LIST', + id: 'list-0', + listType: 'INFORMATIVE', + autoSubmit: false, + selectedIds: [], + items: [{id: '1', title: 'Item 1', icon: {type: 'bullet'}}], + }, + ], + }); + + expect(resultSpy).toHaveBeenCalled(); +}); + +test('showSheet with native implementation ACTIONS_LIST', async () => { + const resultSpy = jest.fn(); + const nativeImplementation = jest.fn(() => + Promise.resolve({ + action: 'SUBMIT' as const, + result: [{id: 'list-0', selectedIds: ['2']}], + }) + ); + + render( + + + + ); + + await act(() => + showSheet({ + type: 'ACTIONS_LIST', + props: { + title: 'Title', + items: [ + {id: '1', title: 'Item 1'}, + {id: '2', title: 'Item 2'}, + ], + }, + }).then(resultSpy) + ); + + expect(nativeImplementation).toHaveBeenCalledWith({ + title: 'Title', + content: [ + { + type: 'LIST', + id: 'list-0', + listType: 'ACTIONS', + autoSubmit: true, + selectedIds: [], + items: [ + {id: '1', title: 'Item 1'}, + {id: '2', title: 'Item 2'}, + ], + }, + ], + }); + + expect(resultSpy).toHaveBeenCalledWith({action: 'SUBMIT', selectedId: '2'}); +}); + +test('showSheet with native implementation RADIO_LIST', async () => { + const resultSpy = jest.fn(); + const nativeImplementation = jest.fn(() => + Promise.resolve({ + action: 'SUBMIT' as const, + result: [{id: 'list-0', selectedIds: ['2']}], + }) + ); + + render( + + + + ); + + await act(() => + showSheet({ + type: 'RADIO_LIST', + props: { + title: 'Title', + selectedId: '1', + items: [ + {id: '1', title: 'Item 1'}, + {id: '2', title: 'Item 2'}, + ], + }, + }).then(resultSpy) + ); + + expect(nativeImplementation).toHaveBeenCalledWith({ + title: 'Title', + content: [ + { + type: 'LIST', + id: 'list-0', + listType: 'SINGLE_SELECTION', + autoSubmit: true, + selectedIds: ['1'], + items: [ + {id: '1', title: 'Item 1'}, + {id: '2', title: 'Item 2'}, + ], + }, + ], + }); + + expect(resultSpy).toHaveBeenCalledWith({action: 'SUBMIT', selectedId: '2'}); +}); + +test('showSheet with native implementation ACTIONS', async () => { + const resultSpy = jest.fn(); + const nativeImplementation = jest.fn(() => + Promise.resolve({ + action: 'SUBMIT' as const, + result: [{id: 'bottom-actions-0', selectedIds: ['LINK']}], + }) + ); + + render( + + + + ); + + await act(() => + showSheet({ + type: 'ACTIONS', + props: { + title: 'Title', + subtitle: 'Subtitle', + description: 'Description', + button: {text: 'Button'}, + secondaryButton: {text: 'Secondary button'}, + link: {text: 'Button link', withChevron: true}, + }, + }).then(resultSpy) + ); + + expect(nativeImplementation).toHaveBeenCalledWith({ + title: 'Title', + subtitle: 'Subtitle', + description: 'Description', + content: [ + { + type: 'BOTTOM_ACTIONS', + id: 'bottom-actions-0', + button: {text: 'Button'}, + secondaryButton: {text: 'Secondary button'}, + link: {text: 'Button link', withChevron: true}, + }, + ], + }); + + expect(resultSpy).toHaveBeenCalledWith({action: 'LINK'}); +}); diff --git a/src/dialog.tsx b/src/dialog.tsx index 65c88447c7..61ae660b0a 100644 --- a/src/dialog.tsx +++ b/src/dialog.tsx @@ -13,7 +13,7 @@ import {Text5, Text4, Text3} from './text'; import {ESC} from './utils/key-codes'; import Box from './box'; import {isRunningAcceptanceTest} from './utils/platform'; -import {useSetModalState} from './modal-context-provider'; +import {useSetModalStateEffect} from './modal-context-provider'; import Stack from './stack'; import * as styles from './dialog.css'; import {vars} from './skins/skin-contract.css'; @@ -303,13 +303,7 @@ const ModalDialog = (props: ModalDialogProps) => { }; }, [addKeyDownListener, handleKeyDown, props, renderNative, platformOverrides]); - const setModalState = useSetModalState(); - React.useEffect(() => { - setModalState({isModalOpen: true}); - return () => { - setModalState({isModalOpen: false}); - }; - }, [setModalState]); + useSetModalStateEffect(); /* eslint-disable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/no-static-element-interactions */ return renderNative ? ( diff --git a/src/index.tsx b/src/index.tsx index ec63bb6be3..35d0f52e06 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -105,6 +105,16 @@ export {default as Video} from './video'; export type {VideoElement} from './video'; export {Carousel, CenteredCarousel, Slideshow, PageBullets} from './carousel'; export {Grid, GridItem} from './grid'; +export { + default as Sheet, + ActionsSheet, + InfoSheet, + ActionsListSheet, + RadioListSheet, + SheetBody, +} from './sheet'; +export {default as SheetRoot, showSheet} from './sheet-root'; +export type {NativeSheetImplementation} from './sheet-root'; export {default as StackingGroup} from './stacking-group'; // Forms diff --git a/src/modal-context-provider.tsx b/src/modal-context-provider.tsx index 5fd6ffb67e..f30af46e5a 100644 --- a/src/modal-context-provider.tsx +++ b/src/modal-context-provider.tsx @@ -29,6 +29,16 @@ const ModalContextProvider = ({children}: {children: React.ReactNode}): JSX.Elem export const useSetModalState = (): ((newModalState: Partial) => void) => React.useContext(ModalStateSetterContext); +export const useSetModalStateEffect = (): void => { + const setModalState = useSetModalState(); + React.useEffect(() => { + setModalState({isModalOpen: true}); + return () => { + setModalState({isModalOpen: false}); + }; + }, [setModalState]); +}; + export const useModalState = (): ModalState => React.useContext(ModalStateContext); export default ModalContextProvider; diff --git a/src/radio-button.css.ts b/src/radio-button.css.ts index 32a91aa7e1..b7b4e25b2a 100644 --- a/src/radio-button.css.ts +++ b/src/radio-button.css.ts @@ -45,7 +45,7 @@ export const innerCircleChecked = style([ }), { opacity: 1, - transform: 'scale(1)', + transform: 'none', }, ]); diff --git a/src/responsive-layout.css.ts b/src/responsive-layout.css.ts index e4ee625d3f..595c6ca6f6 100644 --- a/src/responsive-layout.css.ts +++ b/src/responsive-layout.css.ts @@ -30,6 +30,7 @@ export const responsiveLayout = style({ '@media': { [mq.largeDesktop]: { width: LARGE_DESKTOP_MAX_WIDTH, + maxWidth: `calc(100% - ${SMALL_DESKTOP_SIDE_MARGIN * 2}px)`, // to make ResponsiveLayout work inside desktop modals }, [mq.desktop]: { margin: `0 ${SMALL_DESKTOP_SIDE_MARGIN}px`, diff --git a/src/sheet-root.tsx b/src/sheet-root.tsx new file mode 100644 index 0000000000..eb19dbb80f --- /dev/null +++ b/src/sheet-root.tsx @@ -0,0 +1,379 @@ +import * as React from 'react'; +import {ActionsSheet, ActionsListSheet, InfoSheet, RadioListSheet} from './sheet'; +import Image from './image'; +import {useTheme} from './hooks'; + +import type {ExclusifyUnion, Id} from './utils/utility-types'; + +type InfoIcon = ExclusifyUnion< + | { + type: 'small' | 'regular'; + url: string; + urlDark?: string; + } + | {type: 'bullet'} +>; + +type SheetProps = Id< + { + title?: string; + subtitle?: string; + description?: string; + } & T +>; + +type SheetPropsByType = { + RADIO_LIST: SheetProps<{ + selectedId?: string; + items: Array<{ + id: string; + title?: string; + description?: string; + icon?: { + size?: 'small' | 'large'; + url: string; + urlDark?: string; + }; + }>; + }>; + ACTIONS_LIST: SheetProps<{ + items: Array<{ + id: string; + title: string; + style?: 'normal' | 'destructive'; + icon?: { + url: string; + urlDark?: string; + }; + }>; + }>; + INFO: SheetProps<{ + items: Array<{ + id: string; + title: string; + description?: string; + icon: InfoIcon; + }>; + }>; + ACTIONS: SheetProps<{ + button: { + text: string; + }; + secondaryButton?: { + text: string; + }; + link?: { + text: string; + withChevron?: boolean; + }; + }>; +}; + +type SheetResultByType = { + RADIO_LIST: {action: 'SUBMIT'; selectedId: string} | {action: 'DISMISS'}; + ACTIONS_LIST: {action: 'SUBMIT'; selectedId: string} | {action: 'DISMISS'}; + INFO: void; + ACTIONS: {action: 'PRIMARY' | 'SECONDARY' | 'LINK' | 'DISMISS'}; +}; + +type SheetType = keyof SheetPropsByType; + +type SheetTypeWithProps = Id<{type: T; props: SheetPropsByType[T]}>; + +type SheetTypeWithPropsUnion = { + [T in SheetType]: SheetTypeWithProps; +}[SheetType]; + +export type NativeSheetImplementation = typeof import('@tef-novum/webview-bridge')['bottomSheet']; + +type SheetPropsListener = (sheetProps: SheetTypeWithPropsUnion) => void; +type SheetPromiseResolve = ( + value: T extends SheetType ? SheetResultByType[T] : 'You must provide a type parameter' +) => void; + +let listener: SheetPropsListener | null = null; +let sheetPromiseResolve: SheetPromiseResolve | null = null; +let nativeSheetImplementation: NativeSheetImplementation | null = null; + +const showRadioListNativeSheet = ({ + title, + subtitle, + description, + selectedId, + items, +}: SheetPropsByType['RADIO_LIST']) => + (nativeSheetImplementation as NativeSheetImplementation)({ + title, + subtitle, + description, + content: [ + { + type: 'LIST', + id: 'list-0', + listType: 'SINGLE_SELECTION', + autoSubmit: true, + selectedIds: typeof selectedId === 'string' ? [selectedId] : [], + items, + }, + ], + }).then(({action, result}) => { + if (action === 'SUBMIT') { + return { + action, + selectedId: result[0].selectedIds[0], + }; + } else { + return { + action, + selectedId: null, + }; + } + }); + +const showActionsListNativeSheet = ({ + title, + subtitle, + description, + items, +}: SheetPropsByType['ACTIONS_LIST']) => + (nativeSheetImplementation as NativeSheetImplementation)({ + title, + subtitle, + description, + content: [ + { + type: 'LIST', + id: 'list-0', + listType: 'ACTIONS', + autoSubmit: true, + selectedIds: [], + items, + }, + ], + }).then(({action, result}) => { + if (action === 'SUBMIT') { + return { + action, + selectedId: result[0].selectedIds[0], + }; + } else { + return { + action, + selectedId: null, + }; + } + }); + +const showInfoNativeSheet = async ({title, subtitle, description, items}: SheetPropsByType['INFO']) => { + await (nativeSheetImplementation as NativeSheetImplementation)({ + title, + subtitle, + description, + content: [ + { + type: 'LIST', + id: 'list-0', + listType: 'INFORMATIVE', + autoSubmit: false, + selectedIds: [], + items, + }, + ], + }); +}; + +const showActionsNativeSheet = async ({ + title, + subtitle, + description, + button, + secondaryButton, + link, +}: SheetPropsByType['ACTIONS']) => { + return (nativeSheetImplementation as NativeSheetImplementation)({ + title, + subtitle, + description, + content: [ + { + type: 'BOTTOM_ACTIONS', + id: 'bottom-actions-0', + button, + secondaryButton, + link, + }, + ], + }).then(({action, result}) => { + if (action === 'SUBMIT') { + const bottomActionsResult = result.find(({id}) => id === 'bottom-actions-0'); + const pressedAction = bottomActionsResult?.selectedIds[0]; + if (pressedAction === 'PRIMARY' || pressedAction === 'SECONDARY' || pressedAction === 'LINK') { + return { + action: pressedAction, + }; + } + } + return { + action: 'DISMISS', + }; + }); +}; + +let isSheetOpen = false; +export const showSheet = ( + sheetProps: SheetTypeWithProps +): Promise => { + if (nativeSheetImplementation) { + const {type, props} = sheetProps as SheetTypeWithPropsUnion; + switch (type) { + case 'INFO': + return showInfoNativeSheet(props) as Promise; + case 'ACTIONS_LIST': + return showActionsListNativeSheet(props) as Promise; + case 'RADIO_LIST': + return showRadioListNativeSheet(props) as Promise; + case 'ACTIONS': + return showActionsNativeSheet(props) as Promise; + default: + const unknownType: never = type; + throw new Error(`Unknown sheet type: ${unknownType}`); + } + } + + if (!listener) { + return Promise.reject(new Error('Tried to show a Sheet but the SheetRoot component was not mounted')); + } + + if (isSheetOpen) { + return Promise.reject(new Error('Tried to show a Sheet but there is already one open')); + } + + isSheetOpen = true; + listener(sheetProps as SheetTypeWithPropsUnion); + + const sheetPromise = new Promise((resolve) => { + sheetPromiseResolve = resolve; + }); + + sheetPromise.then(() => { + isSheetOpen = false; + }); + + return sheetPromise as Promise; +}; + +type Props = { + nativeImplementation?: NativeSheetImplementation; +}; + +export const SheetRoot = (props: Props): React.ReactElement | null => { + const {isDarkMode} = useTheme(); + const [sheetProps, setSheetProps] = React.useState(null); + const selectionRef = React.useRef(null); + + React.useEffect(() => { + if (props.nativeImplementation) { + nativeSheetImplementation = props.nativeImplementation; + return () => { + nativeSheetImplementation = null; + }; + } + }, [props.nativeImplementation]); + + React.useEffect(() => { + listener = (newSheetProps: SheetTypeWithProps) => { + selectionRef.current = null; + setSheetProps(newSheetProps as SheetTypeWithPropsUnion); + }; + return () => { + listener = null; + }; + }, []); + + if (!sheetProps || props.nativeImplementation) { + return null; + } + + const handleClose = () => { + setSheetProps(null); + switch (sheetProps.type) { + case 'INFO': + sheetPromiseResolve?.<'INFO'>(undefined); + break; + case 'ACTIONS_LIST': + if (selectionRef.current) { + sheetPromiseResolve?.<'ACTIONS_LIST'>({ + action: 'SUBMIT', + selectedId: selectionRef.current, + }); + } else { + sheetPromiseResolve?.<'ACTIONS_LIST'>({action: 'DISMISS'}); + } + break; + case 'RADIO_LIST': + if (selectionRef.current) { + sheetPromiseResolve?.<'RADIO_LIST'>({action: 'SUBMIT', selectedId: selectionRef.current}); + } else { + sheetPromiseResolve?.<'RADIO_LIST'>({action: 'DISMISS'}); + } + break; + case 'ACTIONS': + if ( + selectionRef.current === 'PRIMARY' || + selectionRef.current === 'SECONDARY' || + selectionRef.current === 'LINK' + ) { + sheetPromiseResolve?.<'ACTIONS'>({action: selectionRef.current}); + } else { + sheetPromiseResolve?.<'ACTIONS'>({action: 'DISMISS'}); + } + break; + default: + // @ts-expect-error sheetProps is never + throw new Error(`Unknown sheet type: ${sheetProps.type}`); + } + }; + + const handleSelect = (id: string) => { + selectionRef.current = id; + }; + + switch (sheetProps.type) { + case 'INFO': + return ; + case 'ACTIONS_LIST': + return ; + case 'RADIO_LIST': + return ( + ({ + ...item, + asset: item.icon && ( + + ), + }))} + onClose={handleClose} + onSelect={handleSelect} + /> + ); + case 'ACTIONS': + return ( + + ); + default: + // @ts-expect-error sheetProps is never. This switch is exhaustive. + throw new Error(`Unknown sheet type: ${sheetProps.type}`); + } +}; + +export default SheetRoot; diff --git a/src/sheet.css.ts b/src/sheet.css.ts new file mode 100644 index 0000000000..59863d1068 --- /dev/null +++ b/src/sheet.css.ts @@ -0,0 +1,275 @@ +import {keyframes, style} from '@vanilla-extract/css'; +import * as mq from './media-queries.css'; +import {vars as skinVars} from './skins/skin-contract.css'; +import {sprinkles} from './sprinkles.css'; + +export const transitionDuration = process.env.NODE_ENV === 'test' ? 0 : 400; + +const sheetClosedStyle = { + transform: 'translateY(100%)', +}; + +const translateUp = keyframes({ + '0%': sheetClosedStyle, + '100%': {}, +}); + +const modalClosedStyle = { + opacity: 0, + transform: 'scale(.8)', +}; + +const fadeScale = keyframes({ + '0%': modalClosedStyle, + '100%': {}, +}); + +const timmingFunction = 'cubic-bezier(0.32, 0.72, 0, 1)'; + +const topMargin = 64; + +export const SheetContainer = style([ + sprinkles({ + position: 'fixed', + left: 0, + right: 0, + bottom: 0, + }), + { + transition: `transform ${transitionDuration}ms ${timmingFunction}`, + animation: `${translateUp} ${transitionDuration}ms ${timmingFunction}`, + + '@media': { + [mq.desktopOrBigger]: { + pointerEvents: 'none', // allow clicks to go through this layer and hit the overlay + top: 0, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + animationName: fadeScale, + transition: `opacity ${transitionDuration}ms ${timmingFunction}, transform ${transitionDuration}ms ${timmingFunction}`, + }, + }, + }, +]); + +export const Sheet = style([ + sprinkles({ + background: skinVars.colors.background, + }), + { + userSelect: 'none', + borderTopLeftRadius: skinVars.borderRadii.sheet, + borderTopRightRadius: skinVars.borderRadii.sheet, + + // used for overdrag. A bottom padding would be a simpler solution, but then translateY(100%) wouldn't work as expected + ':after': { + flexBasis: 0, + position: 'absolute', + zIndex: -1, + top: '80%', + left: 0, + right: 0, + background: skinVars.colors.background, + content: '""', + height: '150vh', + }, + + '@media': { + [mq.desktopOrBigger]: { + position: 'relative', + pointerEvents: 'initial', // restore pointer events (disabled by parent SheetContainer) to work inside the modal + borderRadius: skinVars.borderRadii.sheet, + overflow: 'hidden', + userSelect: 'initial', + + ':after': { + display: 'none', + }, + }, + }, + }, +]); + +export const closingSheet = style({ + ...sheetClosedStyle, + '@media': { + [mq.desktopOrBigger]: modalClosedStyle, + }, +}); + +export const SheetContent = style([ + sprinkles({ + paddingTop: 32, // drag handle height + display: 'flex', + flexDirection: 'column', + }), + { + maxHeight: [`calc(100vh - ${topMargin}px)`, `calc(100dvh - ${topMargin}px)`], + minHeight: 100, + + '@media': { + [mq.desktopOrBigger]: { + width: 680, + paddingTop: 0, + maxHeight: ['560px', 'min(calc(100vh - 64px), 560px)'], + }, + }, + }, +]); + +export const children = sprinkles({ + overflowY: 'auto', + flex: 1, + display: 'flex', + flexDirection: 'column', +}); + +export const handleContainer = style([ + sprinkles({ + // Absolute positioned with a bigger size to increase the touchable area for dragging + position: 'absolute', + top: 0, + height: 64, + paddingY: 8, + width: '100%', + display: 'flex', + justifyContent: 'center', + }), + { + zIndex: 1, + touchAction: 'none', // prevent scrolling while dragging + + '@media': { + [mq.desktopOrBigger]: { + display: 'none', + }, + }, + }, +]); + +export const handle = sprinkles({ + background: skinVars.colors.control, + width: 24, + height: 4, + borderRadius: 2, +}); + +export const modalCloseButton = style([ + sprinkles({ + position: 'absolute', + top: 0, + right: 0, + padding: 16, + }), + { + '@media': { + [mq.tabletOrSmaller]: { + display: 'none', + }, + }, + }, +]); + +export const modalCloseButtonIcon = style([ + sprinkles({ + width: 32, + height: 32, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: '50%', + }), + { + transition: 'background-color 0.2s ease-in-out', + '@media': { + [mq.supportsHover]: { + selectors: { + ':not(:disabled) > &:hover': { + background: skinVars.colors.backgroundContainerHover, + }, + ':not(:disabled) > &:active': { + background: skinVars.colors.backgroundContainerPressed, + }, + }, + }, + }, + }, +]); + +const overlayClosedStyle = {opacity: 0}; +const overlayAnimation = keyframes({ + '0%': overlayClosedStyle, +}); + +export const overlay = style([ + sprinkles({ + position: 'fixed', + background: skinVars.colors.backgroundOverlay, + top: 0, + left: 0, + right: 0, + bottom: 0, + }), + { + touchAction: 'none', + animation: `${overlayAnimation} ${transitionDuration}ms ${timmingFunction}`, + transition: `opacity ${transitionDuration}ms ${timmingFunction}`, + }, +]); + +export const closingOverlay = style(overlayClosedStyle); + +export const stickyTitle = sprinkles({ + position: 'sticky', + top: 0, + background: skinVars.colors.background, +}); + +export const stickyButtons = sprinkles({ + position: 'sticky', + bottom: 0, + background: skinVars.colors.background, +}); + +export const bodyContent = style({ + '@media': { + [mq.desktopOrBigger]: { + overflowY: 'auto', + flex: 1, + }, + }, +}); + +export const sheetActionRow = style([ + sprinkles({ + display: 'flex', + padding: 16, + minHeight: 72, + alignItems: 'center', + }), + { + transition: 'background-color 0.1s ease-in-out', + ':active': { + background: skinVars.colors.backgroundContainerPressed, + }, + '@media': { + [mq.supportsHover]: { + ':hover': { + background: skinVars.colors.backgroundContainerHover, + }, + // need to repeat this inside of @media to avoid :hover background to take precedence over :active + ':active': { + background: skinVars.colors.backgroundContainerPressed, + }, + }, + }, + }, +]); + +export const infoItemIcon = sprinkles({ + display: 'flex', + alignItems: 'center', + height: 24, +}); diff --git a/src/sheet.tsx b/src/sheet.tsx new file mode 100644 index 0000000000..d1b4ba2b89 --- /dev/null +++ b/src/sheet.tsx @@ -0,0 +1,752 @@ +import classnames from 'classnames'; +import * as React from 'react'; +import * as styles from './sheet.css'; +import FocusTrap from './focus-trap'; +import {useAriaId, useIsInViewport, useScreenSize, useTheme} from './hooks'; +import {useSetModalStateEffect} from './modal-context-provider'; +import {Portal} from './portal'; +import {Text2, Text3, Text5} from './text'; +import {vars as skinVars} from './skins/skin-contract.css'; +import {RadioGroup} from './radio-button'; +import {Row, RowList} from './list'; +import ResponsiveLayout from './responsive-layout'; +import NegativeBox from './negative-box'; +import Stack from './stack'; +import Box from './box'; +import Touchable from './touchable'; +import Inline from './inline'; +import Circle from './circle'; +import Divider from './divider'; +import {getPrefixedDataAttributes, getScrollableParentElement} from './utils/dom'; +import {ButtonLink, ButtonPrimary, ButtonSecondary} from './button'; +import IconCloseRegular from './generated/mistica-icons/icon-close-regular'; +import IconButton from './icon-button'; +import ButtonLayout from './button-layout'; +import Image from './image'; + +import type {ExclusifyUnion} from './utils/utility-types'; +import type {DataAttributes, IconProps, RendersNullableElement, TrackingEvent} from './utils/types'; + +const getClientY = (ev: TouchEvent | MouseEvent | React.TouchEvent | React.MouseEvent) => { + if ('touches' in ev) { + return ev.touches[0].clientY; + } + return ev.clientY; +}; + +const useDraggableSheetProps = ({closeModal}: {closeModal: () => void}) => { + const [dragDistance, setDragDistance] = React.useState(0); + const isDraggingRef = React.useRef(false); + const initialMoveEventsCount = React.useRef(0); + const dragInitTimeRef = React.useRef(0); + const initialYRef = React.useRef(0); + const currentYRef = React.useRef(0); + + const {isDesktopOrBigger} = useScreenSize(); + + const handleTouchStart = React.useCallback((ev: React.TouchEvent | React.MouseEvent) => { + isDraggingRef.current = true; + initialMoveEventsCount.current = 0; + dragInitTimeRef.current = Date.now(); + initialYRef.current = getClientY(ev); + }, []); + + const handleScroll = React.useCallback(() => { + isDraggingRef.current = false; + setDragDistance(0); + }, []); + + React.useEffect(() => { + if (isDesktopOrBigger) { + return; + } + + const handleTouchMove = (ev: TouchEvent | MouseEvent) => { + if (!isDraggingRef.current) { + return; + } + + // we discard the first move events to allow scroll events to have priority. When the first + // scroll event is fired, dragging is disabled. If no scroll event is fired, we continue + // handling the dragging. After doing some tests in Android/iOS, 3 seems like a good number + if (initialMoveEventsCount.current < 3) { + initialMoveEventsCount.current++; + return; + } + + currentYRef.current = getClientY(ev); + + setDragDistance(currentYRef.current - initialYRef.current); + }; + + const handleTouchEnd = () => { + if (!isDraggingRef.current) { + return; + } + const dragTime = Date.now() - dragInitTimeRef.current; + const dragDistance = currentYRef.current - initialYRef.current; + const dragSpeed = dragDistance / dragTime; + + isDraggingRef.current = false; + setDragDistance(0); + if (dragDistance > 50 && (currentYRef.current > window.innerHeight * 0.75 || dragSpeed > 0.5)) { + closeModal(); + } + }; + + document.addEventListener('touchmove', handleTouchMove); + document.addEventListener('touchend', handleTouchEnd); + document.addEventListener('mousemove', handleTouchMove); + document.addEventListener('mouseup', handleTouchEnd); + + return () => { + document.removeEventListener('touchmove', handleTouchMove); + document.removeEventListener('touchend', handleTouchEnd); + document.removeEventListener('mousemove', handleTouchMove); + document.removeEventListener('mouseup', handleTouchEnd); + }; + }, [closeModal, isDesktopOrBigger]); + + if (isDesktopOrBigger) { + return {}; + } + + return { + onTouchStart: handleTouchStart, + onMouseDown: handleTouchStart, + style: dragDistance + ? { + transform: `translateY(${dragDistance}px)`, + transition: 'none', + } + : undefined, + onScroll: handleScroll, + overlayStyle: dragDistance + ? { + // decrease opacity when dragging down the sheet + opacity: 0.25 + 1 - dragDistance / (window.innerHeight - initialYRef.current), + transition: 'none', + } + : undefined, + }; +}; + +const useLockBodyScrollStyleElement = () => { + React.useLayoutEffect(() => { + const scrollY = window.scrollY; + // When the modal is shown, we want a fixed body (no-scroll) + document.body.style.top = `-${scrollY}px`; + return () => { + if (process.env.NODE_ENV !== 'test') { + window.scrollTo(0, scrollY); + } + }; + }, []); + + // disable pull down to refresh when the modal is open + // disable body scroll when the modal is open + const bodyStyle = ` + body { + position: fixed; + left: 0; + right: 0; + overscroll-behavior-y: contain; + overflow: hidden; + } + `; + + return ; +}; + +type ModalState = 'closed' | 'opening' | 'open' | 'closing' | 'closed'; +type ModalAction = 'close' | 'open' | 'transitionEnd'; + +const transitions: Record>> = { + closed: { + open: 'opening', + }, + opening: { + close: 'closed', + transitionEnd: 'open', + }, + open: { + close: 'closing', + }, + closing: { + transitionEnd: 'closed', + }, +}; + +const modalReducer = (state: ModalState, action: ModalAction): ModalState => + transitions[state][action] || state; + +type SheetProps = { + onClose?: () => void; + dataAttributes?: DataAttributes; + children: + | React.ReactNode + | ((renderParams: {closeModal: () => void; modalTitleId: string}) => React.ReactNode); +}; + +const Sheet = React.forwardRef(({onClose, children, dataAttributes}, ref) => { + const {texts} = useTheme(); + const [modalState, dispatch] = React.useReducer(modalReducer, 'closed'); + const initRef = React.useRef(false); + const modalTitleId = useAriaId(); + + const handleTransitionEnd = React.useCallback((ev: React.AnimationEvent | React.TransitionEvent) => { + // Don't trigger transitionEnd if the event is not triggered by the sheet element. + if (ev.target === ev.currentTarget) { + dispatch('transitionEnd'); + } + }, []); + + const closeModal = () => { + if (modalState === 'open') { + dispatch('close'); + } + }; + + // transitionEnd/animationEnd dom events may not trigger in some cases, so we use a timeout as fallback + React.useEffect(() => { + if (modalState === 'opening' || modalState === 'closing') { + const tid = setTimeout(() => { + dispatch('transitionEnd'); + }, styles.transitionDuration); + + return () => clearTimeout(tid); + } + }, [modalState]); + + React.useEffect(() => { + if (modalState === 'closed') { + if (initRef.current) { + onClose?.(); + } else { + dispatch('open'); + } + } else { + initRef.current = true; + } + }, [modalState, onClose]); + + const {onScroll, overlayStyle, ...dragableSheetProps} = useDraggableSheetProps({closeModal}); + + useSetModalStateEffect(); + const bodyStyle = useLockBodyScrollStyleElement(); + + if (modalState === 'closed') { + return null; + } + + return ( + + + {bodyStyle} + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} + + + + + + + + + {typeof children === 'function' + ? children({closeModal, modalTitleId}) + : children} + + + + + + + + + + + + + + ); +}); + +type SheetBodyProps = { + title?: string; + subtitle?: string; + description?: string; + button?: RendersNullableElement; + secondaryButton?: RendersNullableElement; + link?: RendersNullableElement; + modalTitleId: string; + children?: React.ReactNode; +}; + +export const SheetBody = ({ + title, + subtitle, + description, + modalTitleId, + button, + secondaryButton, + link, + children, +}: SheetBodyProps): JSX.Element => { + const topScrollSignalRef = React.useRef(null); + const bottomScrollSignalRef = React.useRef(null); + const scrollableParentRef = React.useRef(null); + + React.useEffect(() => { + if (bottomScrollSignalRef.current) { + scrollableParentRef.current = getScrollableParentElement(bottomScrollSignalRef.current); + } + }, []); + + const showTitleDivider = !useIsInViewport(topScrollSignalRef, true, { + root: scrollableParentRef.current, + }); + const showButtonsDivider = !useIsInViewport(bottomScrollSignalRef, true, { + rootMargin: '1px', // bottomScrollSignal div has 0px height so we need a 1px margin to trigger the intersection observer + root: scrollableParentRef.current, + }); + + const hasButtons = !!button || !!secondaryButton || !!link; + return ( + <> + + + {title ? ( + + + + {title} + + + + ) : ( + + )} + {showTitleDivider && } + + + + + + {subtitle || description ? ( + + {subtitle && ( + + {subtitle} + + )} + {description && ( + + {description} + + )} + + ) : null} + {children} + + + + + {hasButtons && ( + + {showButtonsDivider && } + + + + {button} + {secondaryButton} + + + + + )} + + > + ); +}; + +type RadioListSheetProps = { + title?: string; + subtitle?: string; + description?: string; + items: Array<{ + id: string; + title?: string; + description?: string; + asset?: React.ReactNode; + }>; + selectedId?: string; + onClose?: () => void; + onSelect?: (id: string) => void; + dataAttributes?: DataAttributes; + button?: { + text: string; + }; +}; + +export const RadioListSheet = React.forwardRef( + ({title, subtitle, description, items, selectedId, onClose, onSelect, button, dataAttributes}, ref) => { + const [selectedItemId, setSelectedItemId] = React.useState(selectedId); + const hasSelectedRef = React.useRef(false); + const {isDesktopOrBigger} = useScreenSize(); + const {texts} = useTheme(); + + return ( + + {({closeModal, modalTitleId}) => ( + { + if (hasSelectedRef.current) { + onSelect?.(selectedItemId ?? ''); + } + closeModal(); + }} + > + {button?.text ?? texts.sheetConfirmButton} + + ) : undefined + } + > + + { + setSelectedItemId(value); + hasSelectedRef.current = true; + + // In desktop, the modal is closed with the ButtonPrimary + if (isDesktopOrBigger) { + return; + } + + onSelect?.(value); + // Wait for radio animation to finish before closing the modal + setTimeout(() => { + closeModal(); + }, 200); + }} + > + + {items.map((item) => ( + + ))} + + + + + )} + + ); + } +); + +type ActionsListSheetProps = { + title?: string; + subtitle?: string; + description?: string; + items: Array<{ + id: string; + title: string; + style?: 'normal' | 'destructive'; // "normal" by default + icon?: ExclusifyUnion< + | { + Icon: React.ComponentType; + } + | { + url: string; + urlDark?: string; + } + >; + }>; + onClose?: () => void; + onSelect?: (id: string) => void; + dataAttributes?: DataAttributes; +}; + +export const ActionsListSheet = React.forwardRef( + ({title, subtitle, description, items, onClose, onSelect, dataAttributes}, ref) => { + const {isDarkMode} = useTheme(); + + return ( + + {({closeModal, modalTitleId}) => ( + + + {items.map(({id, style, title, icon}) => ( + { + onSelect?.(id); + closeModal(); + }} + > + + {icon && ( + + {icon.Icon ? ( + + ) : ( + + )} + + )} + + {title} + + + + ))} + + + )} + + ); + } +); + +type InfoSheetProps = { + title?: string; + subtitle?: string; + description?: string; + items: Array<{ + id?: string; + title: string; + description?: string; + icon: ExclusifyUnion< + | { + type: 'regular' | 'small'; + Icon: React.ComponentType; + } + | { + type: 'regular' | 'small'; + url: string; + urlDark?: string; + } + | {type: 'bullet'} + >; + }>; + onClose?: () => void; + dataAttributes?: DataAttributes; +}; + +export const InfoSheet = React.forwardRef( + ({title, subtitle, description, items, onClose, dataAttributes}, ref) => { + const {isDarkMode} = useTheme(); + return ( + + {({modalTitleId}) => ( + + + + {items.map((item, idx) => ( + + + {item.icon.type === 'bullet' ? ( + + ) : item.icon.Icon ? ( + + ) : ( + + )} + + + {item.title} + {item.description && ( + + {item.description} + + )} + + + ))} + + + + )} + + ); + } +); + +type PressedButton = 'PRIMARY' | 'SECONDARY' | 'LINK'; + +type ButtonProps = { + text: string; + trackingEvent?: TrackingEvent | ReadonlyArray; + trackEvent?: boolean; +}; + +type ActionsSheetProps = { + title?: string; + subtitle?: string; + description?: string; + button: ButtonProps; + secondaryButton?: ButtonProps; + buttonLink?: ButtonProps & {withChevron?: boolean}; + onClose?: () => void; + onPressButton?: (pressedButton: PressedButton) => void; + dataAttributes?: DataAttributes; +}; + +export const ActionsSheet = React.forwardRef( + ( + { + title, + subtitle, + description, + button, + secondaryButton, + buttonLink, + onClose, + dataAttributes, + onPressButton, + }, + ref + ) => { + const createPressHandler = (closeModal: () => void, pressedButton: PressedButton) => () => { + onPressButton?.(pressedButton); + closeModal(); + }; + + const getButtonProps = ({text, ...otherProps}: T) => ({ + children: text, + ...otherProps, + }); + + return ( + + {({modalTitleId, closeModal}) => ( + + } + secondaryButton={ + secondaryButton ? ( + + ) : undefined + } + link={ + buttonLink ? ( + + ) : undefined + } + /> + )} + + ); + } +); + +export default Sheet; diff --git a/src/sprinkles.css.ts b/src/sprinkles.css.ts index f1c485d99a..4ca8676220 100644 --- a/src/sprinkles.css.ts +++ b/src/sprinkles.css.ts @@ -42,7 +42,7 @@ const responsiveProperties = defineProperties({ const commonProperties = defineProperties({ properties: { - position: ['relative', 'absolute', 'fixed', 'static'], + position: ['relative', 'absolute', 'fixed', 'static', 'sticky'], display: ['none', 'flex', 'inline-flex', 'block', 'inline', 'inline-block'], flexDirection: ['row', 'column'], justifyContent: ['stretch', 'flex-start', 'center', 'flex-end', 'space-around', 'space-between'], @@ -62,7 +62,8 @@ const commonProperties = defineProperties({ }, borderRadius: ['50%', 2, 4, 8, 16, 20, ...Object.values(vars.borderRadii)], cursor: ['pointer'], - overflow: ['hidden', 'visible'], + overflow: ['hidden', 'visible', 'auto'], + overflowY: ['hidden', 'visible', 'auto'], top: sizes, left: sizes, right: sizes, diff --git a/src/stack.tsx b/src/stack.tsx index 0f1b978d94..ccf51a9db7 100644 --- a/src/stack.tsx +++ b/src/stack.tsx @@ -42,9 +42,14 @@ type Props = { dataAttributes?: DataAttributes; }; -const Stack: React.FC = (props) => { - const {space, className, children, role} = props; - +const Stack: React.FC = ({ + space, + className, + children, + role, + 'aria-labelledby': ariaLabelledby, + dataAttributes, +}) => { const isFlexStack = typeof space === 'string'; return ( @@ -52,11 +57,11 @@ const Stack: React.FC = (props) => { className={classnames(className, isFlexStack ? styles.flexStack : styles.marginStack)} style={assignInlineVars(calcInlineVars(space))} role={role} - aria-labelledby={props['aria-labelledby']} - {...getPrefixedDataAttributes(props.dataAttributes)} + aria-labelledby={ariaLabelledby} + {...getPrefixedDataAttributes(dataAttributes)} > {React.Children.map(children, (child) => ( - {child} + {child} ))} ); diff --git a/src/theme.tsx b/src/theme.tsx index 57172ef582..c4d6e7d10d 100644 --- a/src/theme.tsx +++ b/src/theme.tsx @@ -40,6 +40,7 @@ const TEXTS_ES = { carouselPrevButton: 'anterior', playIconButtonLabel: 'Reproducir', pauseIconButtonLabel: 'Pausar', + sheetConfirmButton: 'Continuar', }; const TEXTS_EN: ThemeTexts = { @@ -75,6 +76,7 @@ const TEXTS_EN: ThemeTexts = { carouselPrevButton: 'previous', playIconButtonLabel: 'Play', pauseIconButtonLabel: 'Pause', + sheetConfirmButton: 'Continue', }; const TEXTS_DE: ThemeTexts = { @@ -110,6 +112,7 @@ const TEXTS_DE: ThemeTexts = { carouselPrevButton: 'vorherige', playIconButtonLabel: 'Abspielen', pauseIconButtonLabel: 'Pausieren', + sheetConfirmButton: 'Fortfahren', }; const TEXTS_PT: ThemeTexts = { @@ -145,6 +148,7 @@ const TEXTS_PT: ThemeTexts = { carouselPrevButton: 'anterior', playIconButtonLabel: 'Reproduzir', pauseIconButtonLabel: 'Pausar', + sheetConfirmButton: 'Continuar', }; export const getTexts = (locale: Locale): typeof TEXTS_ES => { diff --git a/src/utils/utility-types.tsx b/src/utils/utility-types.tsx index 148736f13a..7e0a4088d8 100644 --- a/src/utils/utility-types.tsx +++ b/src/utils/utility-types.tsx @@ -1,5 +1,5 @@ type AllKeys = T extends unknown ? keyof T : never; -type Id = T extends infer U ? {[K in keyof U]: U[K]} : never; +export type Id = T extends infer U ? {[K in keyof U]: U[K]} : never; type _ExclusifyUnion = T extends unknown ? Id, never>>> : never; diff --git a/yarn.lock b/yarn.lock index fea53bb718..8f9257793b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4991,10 +4991,10 @@ __metadata: languageName: node linkType: hard -"@tef-novum/webview-bridge@npm:^3.8.0": - version: 3.8.0 - resolution: "@tef-novum/webview-bridge@npm:3.8.0" - checksum: 0438e76a70b1f41c883380009a71eecbf7fcf78bf7f0a5ec243400841d17b4ba95b5827d9cce3ca6d0276f21dc1cc614ec77fe2f2946d36c81cec6735c66569a +"@tef-novum/webview-bridge@npm:^3.27.0": + version: 3.27.0 + resolution: "@tef-novum/webview-bridge@npm:3.27.0" + checksum: acbbb406bca0b7a836274f0630852837c1ab1369c3128f95086b47469124c16e7b498779af4d3a15a5cfe6cd87ca55d31c5106379bfe40ea80ee9e7a131de9ce languageName: node linkType: hard @@ -5018,9 +5018,9 @@ __metadata: languageName: node linkType: hard -"@telefonica/eslint-config@npm:^1.6.0": - version: 1.6.0 - resolution: "@telefonica/eslint-config@npm:1.6.0" +"@telefonica/eslint-config@npm:^1.7.0": + version: 1.7.0 + resolution: "@telefonica/eslint-config@npm:1.7.0" dependencies: "@babel/eslint-parser": ^7.22.5 "@babel/preset-react": ^7.22.5 @@ -5038,7 +5038,7 @@ __metadata: eslint-plugin-testing-library: ^5.11.0 peerDependencies: eslint: ">=7" - checksum: 4a4d122daf6464af6b80088b0bd885c28a9e0e54170bf86bf388b7ccbe5c8f032ee824e3155c0467f0cab62b09b1dad6acd6c1b7816fd8dbb2e26f35077f0ca0 + checksum: 94619bec6692c9bf2e7348b9b115c83d62db6f8286992fdfecb145e9beaa7f994e99052c8252b5c94bcdf5dacffb6bc5c77bb03523f5e3ae530e59e38c269e39 languageName: node linkType: hard @@ -5094,9 +5094,9 @@ __metadata: "@swc/cli": ^0.1.62 "@swc/core": ^1.3.61 "@swc/jest": ^0.2.26 - "@tef-novum/webview-bridge": ^3.8.0 + "@tef-novum/webview-bridge": ^3.27.0 "@telefonica/acceptance-testing": 2.13.0 - "@telefonica/eslint-config": ^1.6.0 + "@telefonica/eslint-config": ^1.7.0 "@telefonica/libphonenumber": ^2.8.1 "@telefonica/prettier-config": ^1.1.0 "@testing-library/jest-dom": ^5.16.5