diff --git a/.vscode/settings.json b/.vscode/settings.json index e99fe1e..cd5894c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,9 @@ { + "editor.defaultFormatter": "biomejs.biome", + "editor.codeActionsOnSave": { + "quickfix.biome": "explicit", + "source.organizeImports.biome": "explicit" + }, "[html]": { "editor.defaultFormatter": "biomejs.biome" }, @@ -23,7 +28,6 @@ "[typescriptreact]": { "editor.defaultFormatter": "biomejs.biome" }, - "editor.defaultFormatter": "biomejs.biome", "[markdown]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, diff --git a/README.md b/README.md index 28067d2..ea01bc8 100644 --- a/README.md +++ b/README.md @@ -448,6 +448,82 @@ The scroll state provides: - `scrollPosition`: `'top'`, `'bottom'`, `'middle'`, or `undefined` - `currentSnap`: Current snap point index (if using snap points) +#### Creating custom scrollers + +If you need more control over the scrollable area, eg. when you don't want the whole content area to be scrollable, you can disable the default scrolling behavior of the `Sheet.Content` and implement your own scroller with the help of `useScrollPosition` and `useVirtualKeyboard` utility hooks. + +> [!NOTE] +> These hooks are internally used by the `Sheet.Content` component so you shouldn't need to use them unless you are implementing a custom scroller. However they are general purpose hooks so you can use them in other parts of your app as well if you want ๐Ÿ˜Š + +```tsx +import { + Sheet, + useScrollPosition, + useVirtualKeyboard, +} from 'react-modal-sheet'; + +function CustomScrollerExample() { + const [isOpen, setOpen] = useState(false); + + const { scrollRef, scrollPosition } = useScrollPosition({ + isEnabled: isOpen, + }); + + const { keyboardHeight } = useVirtualKeyboard({ + isEnabled: isOpen, + }); + + /** + * If you use `var(--keyboard-inset-height)` CSS variable you can just call + * `useVirtualKeyboard()` without destructuring anything from it: + * + * useVirtualKeyboard({ isEnabled: isOpen }); + */ + + return ( + // Disable default keyboard avoidance + setOpen(false)}> + + + +
+
Some content here...
+ +
+
Long content here...
+
+ +
More content here...
+
+
+
+ +
+ ); +} +``` + ### ๐ŸชŸ iOS Modal View effect In addition to the `Sheet.Backdrop` it's possible to apply a scaling effect to the main app element to highlight the modality of the bottom sheet. This effect mimics the [iOS Modal View](https://developer.apple.com/design/human-interface-guidelines/ios/app-architecture/modality/) presentation style to bring more focus to the sheet and add some delight to the user experience. diff --git a/biome.json b/biome.json index 66da343..170f4d5 100644 --- a/biome.json +++ b/biome.json @@ -7,7 +7,12 @@ }, "files": { "ignoreUnknown": false, - "includes": ["**/src/**", "**/test/**"] + "includes": [ + "**/src/**", + "**/test/**", + "**/example/src/**", + "**/example-ssr/src/**" + ] }, "formatter": { "enabled": true, diff --git a/example-ssr/src/components/sheet-example.tsx b/example-ssr/src/components/sheet-example.tsx index bfc0f78..c7a00b9 100644 --- a/example-ssr/src/components/sheet-example.tsx +++ b/example-ssr/src/components/sheet-example.tsx @@ -1,10 +1,10 @@ 'use client'; import { useRef, useState } from 'react'; -import { styled } from 'styled-components'; import { Sheet, SheetRef } from 'react-modal-sheet'; +import { styled } from 'styled-components'; -const snapPoints = [-50, 0.5, 200, 0]; +const snapPoints = [0, 200, 0.5, -50]; const initialSnap = 1; export function SheetExample() { @@ -31,24 +31,22 @@ export function SheetExample() { - - - - - - - - + + + + + + + - {Array.from({ length: 20 }) - .fill(1) - .map((_, i) => ( - {i + 1} - ))} - - + {Array.from({ length: 20 }) + .fill(1) + .map((_, i) => ( + {i + 1} + ))} + diff --git a/example/biome.json b/example/biome.json deleted file mode 100644 index f716348..0000000 --- a/example/biome.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "root": false, - "extends": ["../biome.json"], - "files": { - "includes": ["**/src/**", "**/index.html", "**/vite.config.mts", "!**/dist"] - }, - "linter": { - "rules": { - "style": { - "noParameterAssign": "error", - "useAsConstAssertion": "error", - "useDefaultParameterLast": "error", - "useEnumInitializers": "error", - "useSelfClosingElements": "error", - "useSingleVarDeclarator": "error", - "noUnusedTemplateLiteral": "error", - "useNumberNamespace": "error", - "noInferrableTypes": "error", - "noUselessElse": "error" - } - } - } -} diff --git a/example/src/assets.d.ts b/example/src/assets.d.ts index ce1873a..8b4b997 100644 --- a/example/src/assets.d.ts +++ b/example/src/assets.d.ts @@ -1,16 +1,16 @@ -declare module "*.jpg" { - const src: string; - export default src; +declare module '*.jpg' { + const src: string; + export default src; } -declare module "*.jpeg" { - const src: string; - export default src; +declare module '*.jpeg' { + const src: string; + export default src; } -declare module "*.png" { - const src: string; - export default src; +declare module '*.png' { + const src: string; + export default src; } -declare module "*.svg" { - const src: string; - export default src; +declare module '*.svg' { + const src: string; + export default src; } diff --git a/example/src/components/App.tsx b/example/src/components/App.tsx index 3b221f6..20bc412 100644 --- a/example/src/components/App.tsx +++ b/example/src/components/App.tsx @@ -1,275 +1,290 @@ -import { MdAccessibility as A11yIcon } from "react-icons/md"; -import { Link, Route, Routes } from "react-router"; -import { styled } from "styled-components"; - import { - FaMap as AppleMapIcon, - FaMusic as AppleMusicIcon, - FaMobile, - FaWalking, - FaKeyboard as KeyboardIcon, - FaLock as LockIcon, - FaMoon as MoonIcon, - FaPaintBrush as PaintIcon, - FaScroll as ScrollIcon, -} from "react-icons/fa"; - + AiOutlineColumnHeight as HeightIcon, + AiOutlineSlack as SlackIcon, + AiOutlineControl as SnapIcon, +} from 'react-icons/ai'; import { - AiOutlineColumnHeight as HeightIcon, - AiOutlineSlack as SlackIcon, - AiOutlineControl as SnapIcon, -} from "react-icons/ai"; + FaMap as AppleMapIcon, + FaMusic as AppleMusicIcon, + FaMobile, + FaWalking, + FaKeyboard as KeyboardIcon, + FaLock as LockIcon, + FaMoon as MoonIcon, + FaPaintBrush as PaintIcon, + FaScroll as ScrollIcon, +} from 'react-icons/fa'; +import { LuScrollText, LuWaypoints } from 'react-icons/lu'; +import { MdAccessibility as A11yIcon } from 'react-icons/md'; +import { Link, Route, Routes } from 'react-router'; +import { styled } from 'styled-components'; -import { AvoidKeyboard } from "./AvoidKeyboard"; -import { ContentHeight } from "./ContentHeight"; -import { CustomStyles } from "./CustomStyles"; -import { DisableDrag } from "./DisableDrag"; -import { FullScreen } from "./FullScreen"; -import { ReducedMotion } from "./ReducedMotion"; -import { Scrollable } from "./Scrollable"; -import { ScrollableSnapPoints } from "./ScrollableSnapPoints"; -import { ShadowDOM } from "./ShadowDOM"; -import { SnapPoints } from "./SnapPoints"; -import { A11y } from "./a11y"; -import { AppleMaps } from "./apple-maps"; -import { AppleMusic } from "./apple-music"; -import { DarkMode, Screen, ScrollView } from "./common"; -import { SlackMessage } from "./slack-message"; +import { AvoidKeyboard } from './AvoidKeyboard'; +import { A11y } from './a11y'; +import { AppleMaps } from './apple-maps'; +import { AppleMusic } from './apple-music'; +import { ContentHeight } from './ContentHeight'; +import { CustomScroller } from './CustomScroller'; +import { CustomStyles } from './CustomStyles'; +import { DarkMode, Screen, ScrollView } from './common'; +import { DisableDrag } from './DisableDrag'; +import { FullScreen } from './FullScreen'; +import { ReducedMotion } from './ReducedMotion'; +import { Scrollable } from './Scrollable'; +import { ScrollableSnapPoints } from './ScrollableSnapPoints'; +import { ShadowDOM } from './ShadowDOM'; +import { SnapPoints } from './SnapPoints'; +import { SlackMessage } from './slack-message'; export function App() { - return ( - - - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - - } - /> - - - - - } - /> - - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - ); + return ( + + + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + ); } const ExampleSelector = () => { - return ( - -
  • - - - Snap points - -
  • + return ( + +
  • + + + Snap points + +
  • + +
  • + + + Use Content Height + +
  • -
  • - - - Use Content Height - -
  • +
  • + + + Custom Styles + +
  • -
  • - - - Custom Styles - -
  • +
  • + + + Scrollable + +
  • -
  • - - - Scrollable - -
  • +
  • + + + Custom Scroller + +
  • -
  • - - - Avoid keyboard - -
  • +
  • + + + Avoid keyboard + +
  • -
  • - - - Scrollable (with snap points) - -
  • +
  • + + + Scrollable (with snap points) + +
  • -
  • - - - Disable drag - -
  • +
  • + + + Disable drag + +
  • -
  • - - - Apple Music - -
  • +
  • + + + Apple Music + +
  • -
  • - - - Apple Maps - -
  • +
  • + + + Apple Maps + +
  • -
  • - - - Slack Message - -
  • +
  • + + + Slack Message + +
  • -
  • - - - Accessible Sheet - -
  • +
  • + + + Accessible Sheet + +
  • -
  • - - - Reduced Motion - -
  • +
  • + + + Reduced Motion + +
  • -
  • - - - Full screen - -
  • +
  • + + + Full screen + +
  • -
  • - - - Shadow DOM - -
  • -
    - ); +
  • + + + Shadow DOM + +
  • +
    + ); }; const ExampleLinks = styled.ul` diff --git a/example/src/components/AvoidKeyboard.tsx b/example/src/components/AvoidKeyboard.tsx index 5e128a5..5cf96f9 100644 --- a/example/src/components/AvoidKeyboard.tsx +++ b/example/src/components/AvoidKeyboard.tsx @@ -1,48 +1,48 @@ -import { useRef } from "react"; -import { Sheet, type SheetRef } from "react-modal-sheet"; -import { styled } from "styled-components"; +import { useRef } from 'react'; +import { Sheet, type SheetRef } from 'react-modal-sheet'; +import { styled } from 'styled-components'; -import { ExampleLayout } from "./ExampleLayout"; -import { Button } from "./common"; +import { ExampleLayout } from './ExampleLayout'; +import { Button } from './common'; export function AvoidKeyboard() { - const sheetRef = useRef(null); - const formRef = useRef(null); + const sheetRef = useRef(null); + const formRef = useRef(null); - return ( - - {({ isOpen, close }) => ( - - - - - -
    { - e.preventDefault(); - alert("Form submitted!"); - }} - > - - - - - - - -
    -
    -
    -
    - -
    - )} -
    - ); + return ( + + {({ isOpen, close }) => ( + + + + + +
    { + e.preventDefault(); + alert('Form submitted!'); + }} + > + + + + + + + +
    +
    +
    +
    + +
    + )} +
    + ); } const Content = styled.div` display: flex; diff --git a/example/src/components/BoxList.tsx b/example/src/components/BoxList.tsx index a719a02..4b2aaca 100644 --- a/example/src/components/BoxList.tsx +++ b/example/src/components/BoxList.tsx @@ -1,22 +1,22 @@ -import { styled } from "styled-components"; +import { styled } from 'styled-components'; export function BoxList({ count = 50 }: { count?: number }) { - return ( - - {Array.from({ length: count }) - .fill(1) - .map((_, i) => ( - - {i + 1} - - ))} - - ); + return ( + + {Array.from({ length: count }) + .fill(1) + .map((_, i) => ( + + {i + 1} + + ))} + + ); } const Wrapper = styled.div` @@ -38,4 +38,5 @@ const Box = styled.div` justify-content: center; font-weight: 700; font-size: 16px; + user-select: none; `; diff --git a/example/src/components/ContentHeight.tsx b/example/src/components/ContentHeight.tsx index 0072dd0..6d909f2 100644 --- a/example/src/components/ContentHeight.tsx +++ b/example/src/components/ContentHeight.tsx @@ -1,67 +1,67 @@ -import { useRef, useState } from "react"; -import { Sheet, type SheetRef } from "react-modal-sheet"; -import { styled } from "styled-components"; -import { Button } from "./common"; -import { ExampleLayout } from "./ExampleLayout"; +import { useRef, useState } from 'react'; +import { Sheet, type SheetRef } from 'react-modal-sheet'; +import { styled } from 'styled-components'; +import { Button } from './common'; +import { ExampleLayout } from './ExampleLayout'; const snapPoints = [0, 200, 1]; const lastSnap = snapPoints.length - 1; export function ContentHeight() { - const [boxes, setBoxes] = useState(1); - const sheetRef = useRef(null); - const snapTo = (i: number) => sheetRef.current?.snapTo(i); + const [boxes, setBoxes] = useState(1); + const sheetRef = useRef(null); + const snapTo = (i: number) => sheetRef.current?.snapTo(i); - return ( - - {({ isOpen, close }) => ( - - - - state.currentSnap !== lastSnap} - > - - - - - - + return ( + + {({ isOpen, close }) => ( + + + + state.currentSnap !== lastSnap} + > + + + + + + - {Array.from({ length: boxes }).map((_, i) => ( - - {i} - - ))} - - - - - - )} - - ); + {Array.from({ length: boxes }).map((_, i) => ( + + {i} + + ))} + + + + + + )} + + ); } const BoxList = styled.div` diff --git a/example/src/components/CustomScroller.tsx b/example/src/components/CustomScroller.tsx new file mode 100644 index 0000000..129353d --- /dev/null +++ b/example/src/components/CustomScroller.tsx @@ -0,0 +1,103 @@ +import { + Sheet, + useScrollPosition, + useVirtualKeyboard, +} from 'react-modal-sheet'; +import styled from 'styled-components'; +import { BoxList } from './BoxList'; +import { ExampleLayout } from './ExampleLayout'; + +export function CustomScroller() { + const { scrollRef, scrollPosition } = useScrollPosition(); + + // This assigns the `--keyboard-inset-height` CSS variable to the `:root` element + useVirtualKeyboard(); + + return ( + + {({ isOpen, close }) => ( + /* Disable default keyboard avoidance */ + + + + + {/* Disable default content scrolling */} + + + + + + + + + Scroll position: {scrollPosition} + + + + + Close + + + + + + + )} + + ); +} + +const Content = styled.div` + height: 100%; + display: grid; + grid-template-rows: auto 1fr auto; + transition: padding-bottom 100ms; + padding-bottom: var(--keyboard-inset-height); +`; + +const Scroller = styled.div` + overflow-y: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior-y: none; +`; + +const ScrollPosition = styled.div` + padding: 8px; + font-size: 14px; + color: #666; + text-align: center; + position: sticky; + top: 0; + backdrop-filter: blur(10px); +`; + +const ContentHeader = styled.div` + padding: 16px; + background: #ccc; + text-align: center; + font-weight: bold; +`; + +const ContentFooter = styled.button` + padding: 16px; + background: #1255a7; + color: white; + text-align: center; +`; + +const Input = styled.input` + padding: 8px; + font-size: 16px; + width: 100%; + border-radius: 8px; + border: 1px solid #ccc; + background: white; + + &:focus { + outline: none; + border-color: #1255a7; + } +`; diff --git a/example/src/components/CustomStyles.tsx b/example/src/components/CustomStyles.tsx index e06ec72..6f9ab2a 100644 --- a/example/src/components/CustomStyles.tsx +++ b/example/src/components/CustomStyles.tsx @@ -1,43 +1,43 @@ -import { Sheet } from "react-modal-sheet"; -import { styled } from "styled-components"; +import { Sheet } from 'react-modal-sheet'; +import { styled } from 'styled-components'; -import { ExampleLayout } from "./ExampleLayout"; -import { Button } from "./common"; +import { ExampleLayout } from './ExampleLayout'; +import { Button } from './common'; export function CustomStyles() { - return ( - - {({ isOpen, close }) => ( - - - - - - - - - - - Custom styles - - This sheet has totally custom styles and its height is dynamic - based on the size of its content. - - - Cancel - - - - - - - - )} - - ); + return ( + + {({ isOpen, close }) => ( + + + + + + + + + + + Custom styles + + This sheet has totally custom styles and its height is dynamic + based on the size of its content. + + + Cancel + + + + + + + + )} + + ); } const SheetContainer = styled(Sheet.Container)` diff --git a/example/src/components/DisableDrag.tsx b/example/src/components/DisableDrag.tsx index dbcd539..dbeb09d 100644 --- a/example/src/components/DisableDrag.tsx +++ b/example/src/components/DisableDrag.tsx @@ -1,64 +1,64 @@ -import { useRef, useState } from "react"; -import { styled } from "styled-components"; -import { Sheet } from "react-modal-sheet"; +import { useRef, useState } from 'react'; +import { styled } from 'styled-components'; +import { Sheet } from 'react-modal-sheet'; -import { ExampleLayout } from "./ExampleLayout"; +import { ExampleLayout } from './ExampleLayout'; export function DisableDrag() { - const { isScrolling, onScroll } = useScrolling(); + const { isScrolling, onScroll } = useScrolling(); - return ( - - {({ isOpen, close }) => ( - - - - - -
    - {isScrolling - ? "Scrolling (drag disabled)" - : "Not scrolling (drag enabled)"} -
    + return ( + + {({ isOpen, close }) => ( + + + + + +
    + {isScrolling + ? 'Scrolling (drag disabled)' + : 'Not scrolling (drag enabled)'} +
    - - {Array.from({ length: 20 }) - .fill(1) - .map((_, i) => ( - - {i} - - ))} - -
    -
    -
    - -
    - )} -
    - ); + + {Array.from({ length: 20 }) + .fill(1) + .map((_, i) => ( + + {i} + + ))} + +
    +
    +
    + +
    + )} +
    + ); } function useScrolling() { - const [isScrolling, setScrolling] = useState(false); - const timeout = useRef(undefined); + const [isScrolling, setScrolling] = useState(false); + const timeout = useRef(undefined); - const onScroll = () => { - setScrolling(true); - clearTimeout(timeout.current); - timeout.current = setTimeout(() => setScrolling(false), 150); - }; + const onScroll = () => { + setScrolling(true); + clearTimeout(timeout.current); + timeout.current = setTimeout(() => setScrolling(false), 150); + }; - return { onScroll, isScrolling }; + return { onScroll, isScrolling }; } const Content = styled.div` diff --git a/example/src/components/ExampleLayout.tsx b/example/src/components/ExampleLayout.tsx index 0ef8387..093f9a1 100644 --- a/example/src/components/ExampleLayout.tsx +++ b/example/src/components/ExampleLayout.tsx @@ -1,138 +1,138 @@ -import { motion, useScroll, useTransform } from "motion/react"; -import { type ReactNode, useRef } from "react"; -import { FiChevronLeft } from "react-icons/fi"; -import { Link } from "react-router"; +import { motion, useScroll, useTransform } from 'motion/react'; +import { type ReactNode, useRef } from 'react'; +import { FiChevronLeft } from 'react-icons/fi'; +import { Link } from 'react-router'; import { - type OverlayTriggerState, - useOverlayTriggerState, -} from "react-stately"; -import { createGlobalStyle, styled } from "styled-components"; + type OverlayTriggerState, + useOverlayTriggerState, +} from 'react-stately'; +import { createGlobalStyle, styled } from 'styled-components'; -import { Button, ScrollView } from "./common"; +import { Button, ScrollView } from './common'; type Props = { - title: string; - description?: string; - children: (state: OverlayTriggerState) => ReactNode; + title: string; + description?: string; + children: (state: OverlayTriggerState) => ReactNode; }; export function ExampleLayout({ title, description, children }: Props) { - const state = useOverlayTriggerState({}); - const scrollRef = useRef(null); - const { scrollY } = useScroll({ axis: "y", container: scrollRef }); - const fabOpacity = useTransform(scrollY, [200, 220], [0, 1]); - const fabTranslateY = useTransform(scrollY, [200, 220], [20, 0]); - - return ( - - - - - Back - - - {title} - - {description && {description}} - - - - - Example content to make the page scrollable... - - - Lorem, ipsum dolor sit amet consectetur adipisicing elit. Recusandae - sint cupiditate eum quibusdam consequuntur quae! Rem error alias - placeat aliquid qui facere dicta veniam tenetur suscipit? Quibusdam - eos est similique excepturi officiis sequi maxime sunt blanditiis - nulla aperiam rem cum totam eligendi eius voluptatem, dolores - repellendus! Iste accusantium vero, sint ipsam dicta saepe - laudantium blanditiis et corporis aliquid deleniti quae vitae - nostrum, repellat illo explicabo accusamus odit pariatur ex. Placeat - dolorum in laboriosam repudiandae maiores aut incidunt eos sequi - consectetur, autem nihil. - - - Quae corrupti veritatis voluptates molestiae ipsam beatae sit quia - aperiam rem! Natus earum quas, quos rerum nisi nostrum deserunt - voluptatibus perspiciatis? Hic, animi harum quam, fugit explicabo ab - accusantium laborum iste rem omnis obcaecati quis earum eligendi in - inventore, mollitia asperiores numquam amet architecto porro at! - Minus, non porro. Harum dolor nihil nemo quisquam! Omnis ipsum - deleniti id laborum incidunt temporibus, ipsa suscipit eius dolorum - voluptatem aut, voluptas a provident tempore. Voluptas asperiores ea - delectus. Ipsa laborum error numquam perferendis similique - voluptates animi suscipit dolore at modi dicta minima, asperiores - corporis nemo, voluptate repellendus, aspernatur illo quo. Aliquid - voluptatem excepturi odio accusamus dignissimos expedita eveniet, - impedit consequatur. Illo fugit placeat possimus est doloremque? - Veritatis quidem, similique sed non sint architecto aliquam - doloribus accusamus aspernatur fugit corporis quae voluptates maxime - at. - - - Aut praesentium, quia architecto ea natus dicta nihil laborum - tempora animi quas voluptas recusandae adipisci nostrum vero amet, - nisi temporibus fugit sequi beatae. Natus fugiat ullam nemo neque - laborum nesciunt, iure totam aut doloribus ea! Fugiat similique eos - vel dicta maxime? Cumque hic perferendis accusantium molestias - laboriosam quod consequuntur, cupiditate fuga accusamus cum - explicabo, magni enim nobis velit numquam atque! Maxime et explicabo - velit distinctio! Quis ad, ipsa eaque iusto adipisci laboriosam - fugiat nihil blanditiis explicabo fugit repudiandae. Quaerat odio - porro doloribus? Perferendis, sit blanditiis. Ab, eligendi impedit. - Consectetur, officia provident! Sed dignissimos suscipit - consequuntur fugit ullam odio incidunt quo sint enim. Ratione modi - aperiam rem non quaerat consectetur, natus maiores impedit et - exercitationem, suscipit facilis debitis, mollitia in molestiae sunt - sit cum optio laboriosam? Fugit officia consequuntur eos voluptate, - quo dicta quas! In nulla sapiente cupiditate sequi! - - - Animi culpa cumque, et voluptas, autem odio tenetur iusto quas amet - quidem ipsam quos dicta dolorem libero, expedita minima maiores - exercitationem esse eos. Vero cumque molestias porro iusto neque, - officiis quaerat nemo. Sunt atque corrupti nobis id sapiente quidem. - Animi officiis corrupti dicta, excepturi, quod, reprehenderit nam - sint accusamus deserunt obcaecati beatae illum ipsum minus nisi - necessitatibus omnis aspernatur eligendi saepe aliquid aperiam. - Ipsam, perspiciatis? Temporibus iure cumque optio accusamus itaque - laboriosam nemo facilis earum, asperiores libero. Quia eveniet - inventore asperiores fuga impedit deserunt iure magni ipsam tenetur. - Eos omnis officia unde quisquam natus eius beatae aperiam. Facilis - vitae veniam aut ducimus consequatur excepturi labore modi dolorum - eveniet? Ex maxime placeat dolore minima hic at necessitatibus - similique voluptatem facilis, ipsa nisi nihil tempora nemo mollitia - nulla dolorum dolor fuga cupiditate veniam assumenda, voluptas, - aspernatur nobis! Minima magni perspiciatis doloribus officia - veritatis, ratione, quas dolore eos impedit numquam doloremque ex - delectus! - - - - - - - - {children(state)} - - -

    - Made in Finland ๐Ÿ‡ซ๐Ÿ‡ฎ by{" "} - - Teemu Taskula - -

    -
    - - -
    -
    - ); + const state = useOverlayTriggerState({}); + const scrollRef = useRef(null); + const { scrollY } = useScroll({ axis: 'y', container: scrollRef }); + const fabOpacity = useTransform(scrollY, [200, 220], [0, 1]); + const fabTranslateY = useTransform(scrollY, [200, 220], [20, 0]); + + return ( + + + + + Back + + + {title} + + {description && {description}} + + + + + Example content to make the page scrollable... + + + Lorem, ipsum dolor sit amet consectetur adipisicing elit. Recusandae + sint cupiditate eum quibusdam consequuntur quae! Rem error alias + placeat aliquid qui facere dicta veniam tenetur suscipit? Quibusdam + eos est similique excepturi officiis sequi maxime sunt blanditiis + nulla aperiam rem cum totam eligendi eius voluptatem, dolores + repellendus! Iste accusantium vero, sint ipsam dicta saepe + laudantium blanditiis et corporis aliquid deleniti quae vitae + nostrum, repellat illo explicabo accusamus odit pariatur ex. Placeat + dolorum in laboriosam repudiandae maiores aut incidunt eos sequi + consectetur, autem nihil. + + + Quae corrupti veritatis voluptates molestiae ipsam beatae sit quia + aperiam rem! Natus earum quas, quos rerum nisi nostrum deserunt + voluptatibus perspiciatis? Hic, animi harum quam, fugit explicabo ab + accusantium laborum iste rem omnis obcaecati quis earum eligendi in + inventore, mollitia asperiores numquam amet architecto porro at! + Minus, non porro. Harum dolor nihil nemo quisquam! Omnis ipsum + deleniti id laborum incidunt temporibus, ipsa suscipit eius dolorum + voluptatem aut, voluptas a provident tempore. Voluptas asperiores ea + delectus. Ipsa laborum error numquam perferendis similique + voluptates animi suscipit dolore at modi dicta minima, asperiores + corporis nemo, voluptate repellendus, aspernatur illo quo. Aliquid + voluptatem excepturi odio accusamus dignissimos expedita eveniet, + impedit consequatur. Illo fugit placeat possimus est doloremque? + Veritatis quidem, similique sed non sint architecto aliquam + doloribus accusamus aspernatur fugit corporis quae voluptates maxime + at. + + + Aut praesentium, quia architecto ea natus dicta nihil laborum + tempora animi quas voluptas recusandae adipisci nostrum vero amet, + nisi temporibus fugit sequi beatae. Natus fugiat ullam nemo neque + laborum nesciunt, iure totam aut doloribus ea! Fugiat similique eos + vel dicta maxime? Cumque hic perferendis accusantium molestias + laboriosam quod consequuntur, cupiditate fuga accusamus cum + explicabo, magni enim nobis velit numquam atque! Maxime et explicabo + velit distinctio! Quis ad, ipsa eaque iusto adipisci laboriosam + fugiat nihil blanditiis explicabo fugit repudiandae. Quaerat odio + porro doloribus? Perferendis, sit blanditiis. Ab, eligendi impedit. + Consectetur, officia provident! Sed dignissimos suscipit + consequuntur fugit ullam odio incidunt quo sint enim. Ratione modi + aperiam rem non quaerat consectetur, natus maiores impedit et + exercitationem, suscipit facilis debitis, mollitia in molestiae sunt + sit cum optio laboriosam? Fugit officia consequuntur eos voluptate, + quo dicta quas! In nulla sapiente cupiditate sequi! + + + Animi culpa cumque, et voluptas, autem odio tenetur iusto quas amet + quidem ipsam quos dicta dolorem libero, expedita minima maiores + exercitationem esse eos. Vero cumque molestias porro iusto neque, + officiis quaerat nemo. Sunt atque corrupti nobis id sapiente quidem. + Animi officiis corrupti dicta, excepturi, quod, reprehenderit nam + sint accusamus deserunt obcaecati beatae illum ipsum minus nisi + necessitatibus omnis aspernatur eligendi saepe aliquid aperiam. + Ipsam, perspiciatis? Temporibus iure cumque optio accusamus itaque + laboriosam nemo facilis earum, asperiores libero. Quia eveniet + inventore asperiores fuga impedit deserunt iure magni ipsam tenetur. + Eos omnis officia unde quisquam natus eius beatae aperiam. Facilis + vitae veniam aut ducimus consequatur excepturi labore modi dolorum + eveniet? Ex maxime placeat dolore minima hic at necessitatibus + similique voluptatem facilis, ipsa nisi nihil tempora nemo mollitia + nulla dolorum dolor fuga cupiditate veniam assumenda, voluptas, + aspernatur nobis! Minima magni perspiciatis doloribus officia + veritatis, ratione, quas dolore eos impedit numquam doloremque ex + delectus! + + + + + + + + {children(state)} + + +

    + Made in Finland ๐Ÿ‡ซ๐Ÿ‡ฎ by{' '} + + Teemu Taskula + +

    +
    + + +
    +
    + ); } const maxWidth = 720; diff --git a/example/src/components/FullScreen.tsx b/example/src/components/FullScreen.tsx index 8c3b0b9..87a8828 100644 --- a/example/src/components/FullScreen.tsx +++ b/example/src/components/FullScreen.tsx @@ -1,27 +1,27 @@ -import { Sheet } from "react-modal-sheet"; -import { styled } from "styled-components"; +import { Sheet } from 'react-modal-sheet'; +import { styled } from 'styled-components'; -import { BoxList } from "./BoxList"; -import { ExampleLayout } from "./ExampleLayout"; +import { BoxList } from './BoxList'; +import { ExampleLayout } from './ExampleLayout'; export function FullScreen() { - return ( - - {({ isOpen, close }) => ( - - - - - - - - - )} - - ); + return ( + + {({ isOpen, close }) => ( + + + + + + + + + )} + + ); } const SheetContainer = styled(Sheet.Container)` diff --git a/example/src/components/ReducedMotion.tsx b/example/src/components/ReducedMotion.tsx index 8d8437d..396cc42 100644 --- a/example/src/components/ReducedMotion.tsx +++ b/example/src/components/ReducedMotion.tsx @@ -1,40 +1,40 @@ -import { Sheet } from "react-modal-sheet"; -import { styled } from "styled-components"; +import { Sheet } from 'react-modal-sheet'; +import { styled } from 'styled-components'; -import { ExampleLayout } from "./ExampleLayout"; +import { ExampleLayout } from './ExampleLayout'; export function ReducedMotion() { - return ( - - {({ isOpen, close }) => ( - - - - - - {Array.from({ length: 50 }) - .fill(1) - .map((_, i) => ( - - {i} - - ))} - - - - - - )} - - ); + return ( + + {({ isOpen, close }) => ( + + + + + + {Array.from({ length: 50 }) + .fill(1) + .map((_, i) => ( + + {i} + + ))} + + + + + + )} + + ); } const BoxList = styled.div` diff --git a/example/src/components/Scrollable.tsx b/example/src/components/Scrollable.tsx index ee96265..311bd29 100644 --- a/example/src/components/Scrollable.tsx +++ b/example/src/components/Scrollable.tsx @@ -1,29 +1,29 @@ -import { Sheet } from "react-modal-sheet"; +import { Sheet } from 'react-modal-sheet'; -import { BoxList } from "./BoxList"; -import { ExampleLayout } from "./ExampleLayout"; +import { BoxList } from './BoxList'; +import { ExampleLayout } from './ExampleLayout'; export function Scrollable() { - return ( - - {({ isOpen, close }) => ( - - - - - - - - - - )} - - ); + return ( + + {({ isOpen, close }) => ( + + + + + + + + + + )} + + ); } diff --git a/example/src/components/ScrollableSnapPoints.tsx b/example/src/components/ScrollableSnapPoints.tsx index 3f66a8c..3a61a99 100644 --- a/example/src/components/ScrollableSnapPoints.tsx +++ b/example/src/components/ScrollableSnapPoints.tsx @@ -1,68 +1,68 @@ -import { useRef, useState } from "react"; -import { Sheet, type SheetRef } from "react-modal-sheet"; -import { styled } from "styled-components"; +import { useRef, useState } from 'react'; +import { Sheet, type SheetRef } from 'react-modal-sheet'; +import { styled } from 'styled-components'; -import { BoxList } from "./BoxList"; -import { ExampleLayout } from "./ExampleLayout"; -import { Button } from "./common"; +import { BoxList } from './BoxList'; +import { ExampleLayout } from './ExampleLayout'; +import { Button } from './common'; const snapPoints = [0, 170, 0.5, -200, 1]; const initialSnap = 1; const lastSnap = snapPoints.length - 1; export function ScrollableSnapPoints() { - const [currentSnap, setCurrentSnap] = useState(initialSnap); - const sheetRef = useRef(null); - const snapTo = (i: number) => sheetRef.current?.snapTo(i); + const [currentSnap, setCurrentSnap] = useState(initialSnap); + const sheetRef = useRef(null); + const snapTo = (i: number) => sheetRef.current?.snapTo(i); - return ( - - {({ isOpen, close }) => ( - - - + return ( + + {({ isOpen, close }) => ( + + + - state.currentSnap !== lastSnap} - > -
    - - Current snap point: {currentSnap} - - Content is only scrollable at the upmost snap point - - -
    + state.currentSnap !== lastSnap} + > +
    + + Current snap point: {currentSnap} + + Content is only scrollable at the upmost snap point + + +
    - -
    + +
    -
    - - - - - - - -
    -
    - -
    - )} -
    - ); +
    + + + + + + + +
    +
    + +
    + )} +
    + ); } const Header = styled.div` diff --git a/example/src/components/ShadowDOM.tsx b/example/src/components/ShadowDOM.tsx index b6690bc..424e865 100644 --- a/example/src/components/ShadowDOM.tsx +++ b/example/src/components/ShadowDOM.tsx @@ -1,95 +1,95 @@ -import { useEffect, useState } from "react"; -import { Sheet } from "react-modal-sheet"; +import { useEffect, useState } from 'react'; +import { Sheet } from 'react-modal-sheet'; -import { ExampleLayout } from "./ExampleLayout"; +import { ExampleLayout } from './ExampleLayout'; export function ShadowDOM() { - const shadowRoot = useShadowRoot(); + const shadowRoot = useShadowRoot(); - return ( - - {({ isOpen, close }) => ( - <> - {/* Render the Sheet only when the shadowRoot is ready */} - {shadowRoot && ( - - - - - {/* We used inline styles because the CSS in document.head is outside the shadow DOM */} -
    - {Array.from({ length: 50 }).map((_, i) => ( -
    - {i} -
    - ))} -
    -
    -
    - -
    - )} - - )} -
    - ); + return ( + + {({ isOpen, close }) => ( + <> + {/* Render the Sheet only when the shadowRoot is ready */} + {shadowRoot && ( + + + + + {/* We used inline styles because the CSS in document.head is outside the shadow DOM */} +
    + {Array.from({ length: 50 }).map((_, i) => ( +
    + {i} +
    + ))} +
    +
    +
    + +
    + )} + + )} +
    + ); } -const SHADOW_ROOT_ID = "react-modal-sheet-shadow-root"; +const SHADOW_ROOT_ID = 'react-modal-sheet-shadow-root'; function useShadowRoot() { - const [shadowRoot, setShadowRoot] = useState(null); + const [shadowRoot, setShadowRoot] = useState(null); - useEffect(() => { - // Create a shadow DOM root dynamically if it doesn't already exist - let shadowRootContainer = document.getElementById(SHADOW_ROOT_ID); + useEffect(() => { + // Create a shadow DOM root dynamically if it doesn't already exist + let shadowRootContainer = document.getElementById(SHADOW_ROOT_ID); - if (!shadowRootContainer) { - shadowRootContainer = document.createElement("div"); - shadowRootContainer.id = SHADOW_ROOT_ID; - document.body.appendChild(shadowRootContainer); - } + if (!shadowRootContainer) { + shadowRootContainer = document.createElement('div'); + shadowRootContainer.id = SHADOW_ROOT_ID; + document.body.appendChild(shadowRootContainer); + } - // Attach shadow root and update state - if (!shadowRoot) { - const root = shadowRootContainer.attachShadow({ mode: "open" }); - setShadowRoot(root); - } + // Attach shadow root and update state + if (!shadowRoot) { + const root = shadowRootContainer.attachShadow({ mode: 'open' }); + setShadowRoot(root); + } - return () => { - // Clean up the shadow root when the component is unmounted - if (shadowRoot) { - shadowRoot.host.remove(); - setShadowRoot(null); - } - }; - }, [shadowRoot]); + return () => { + // Clean up the shadow root when the component is unmounted + if (shadowRoot) { + shadowRoot.host.remove(); + setShadowRoot(null); + } + }; + }, [shadowRoot]); - return shadowRoot as HTMLElement | null; + return shadowRoot as HTMLElement | null; } diff --git a/example/src/components/SnapPoints.tsx b/example/src/components/SnapPoints.tsx index 162f6f9..1d44914 100644 --- a/example/src/components/SnapPoints.tsx +++ b/example/src/components/SnapPoints.tsx @@ -1,55 +1,55 @@ -import { useEffect, useRef, useState } from "react"; -import { Sheet, type SheetRef } from "react-modal-sheet"; -import { styled } from "styled-components"; +import { useEffect, useRef, useState } from 'react'; +import { Sheet, type SheetRef } from 'react-modal-sheet'; +import { styled } from 'styled-components'; -import { ExampleLayout } from "./ExampleLayout"; -import { Button } from "./common"; +import { ExampleLayout } from './ExampleLayout'; +import { Button } from './common'; const snapPoints = [0, 100, 0.5, -100, 1]; const initialSnap = 1; // Initial snap point when sheet is opened export function SnapPoints() { - const sheetRef = useRef(null); - const [snapPoint, setSnapPoint] = useState(initialSnap); - const snapTo = (i: number) => sheetRef.current?.snapTo(i); + const sheetRef = useRef(null); + const [snapPoint, setSnapPoint] = useState(initialSnap); + const snapTo = (i: number) => sheetRef.current?.snapTo(i); - useEffect(() => { - console.log("> Current snap point is", snapPoint); - }, [snapPoint]); + useEffect(() => { + console.log('> Current snap point is', snapPoint); + }, [snapPoint]); - return ( - - {({ isOpen, close }) => ( - - - - - - - - - - - - - - - - - - )} - - ); + return ( + + {({ isOpen, close }) => ( + + + + + + + + + + + + + + + + + + )} + + ); } const SheetContentWrapper = styled.div` diff --git a/example/src/components/a11y/A11ySheet.tsx b/example/src/components/a11y/A11ySheet.tsx index 62070bc..0b5772b 100644 --- a/example/src/components/a11y/A11ySheet.tsx +++ b/example/src/components/a11y/A11ySheet.tsx @@ -1,74 +1,74 @@ -import { useRef, type PropsWithChildren } from "react"; -import type { OverlayTriggerState } from "react-stately"; -import { Sheet } from "react-modal-sheet"; +import { useRef, type PropsWithChildren } from 'react'; +import type { OverlayTriggerState } from 'react-stately'; +import { Sheet } from 'react-modal-sheet'; import { - FocusScope, - useDialog, - useOverlay, - useModal, - OverlayProvider, -} from "react-aria"; + FocusScope, + useDialog, + useOverlay, + useModal, + OverlayProvider, +} from 'react-aria'; type SheetProps = { - state: OverlayTriggerState; - label: string; + state: OverlayTriggerState; + label: string; }; export function A11ySheet({ - state, - label, - children, - ...rest + state, + label, + children, + ...rest }: PropsWithChildren) { - return ( - - - - - {children} - - - - - ); + return ( + + + + + {children} + + + + + ); } const A11ySheetContent = ({ - state, - label, - children, + state, + label, + children, }: PropsWithChildren) => { - const a11yProps = useA11ySheet(state, label); + const a11yProps = useA11ySheet(state, label); - return ( - <> - - - {children} - - - - ); + return ( + <> + + + {children} + + + + ); }; const useA11ySheet = (state: OverlayTriggerState, label: string) => { - const ref = useRef(null); - const dialog = useDialog({ "aria-label": label }, ref); - const overlay = useOverlay( - { - onClose: state.close, - isOpen: true, - isDismissable: true, - }, - ref, - ); + const ref = useRef(null); + const dialog = useDialog({ 'aria-label': label }, ref); + const overlay = useOverlay( + { + onClose: state.close, + isOpen: true, + isDismissable: true, + }, + ref + ); - useModal(); + useModal(); - return { - ref, - ...overlay.overlayProps, - ...dialog.dialogProps, - } as any; // HACK: fix type conflicts with Framer Motion + return { + ref, + ...overlay.overlayProps, + ...dialog.dialogProps, + } as any; // HACK: fix type conflicts with Framer Motion }; diff --git a/example/src/components/a11y/index.tsx b/example/src/components/a11y/index.tsx index 216265b..9a391aa 100644 --- a/example/src/components/a11y/index.tsx +++ b/example/src/components/a11y/index.tsx @@ -1,24 +1,24 @@ -import { styled } from "styled-components"; +import { styled } from 'styled-components'; -import { A11ySheet } from "./A11ySheet"; -import { ExampleLayout } from "../ExampleLayout"; +import { A11ySheet } from './A11ySheet'; +import { ExampleLayout } from '../ExampleLayout'; export function A11y() { - return ( - - {(state) => ( - - - This is a simple a11y sheet ๐Ÿฆพ -

    Your content goes here...

    -
    -
    - )} -
    - ); + return ( + + {(state) => ( + + + This is a simple a11y sheet ๐Ÿฆพ +

    Your content goes here...

    +
    +
    + )} +
    + ); } const SheetContent = styled.div` diff --git a/example/src/components/apple-maps/index.tsx b/example/src/components/apple-maps/index.tsx index d137ed8..b8e9823 100644 --- a/example/src/components/apple-maps/index.tsx +++ b/example/src/components/apple-maps/index.tsx @@ -1,139 +1,139 @@ import { - AnimatePresence, - interpolate, - motion, - useMotionTemplate, - useScroll, - useTransform, -} from "motion/react"; -import { useRef, useState } from "react"; -import { FiChevronLeft, FiSearch } from "react-icons/fi"; -import { Sheet, type SheetRef } from "react-modal-sheet"; -import { Link } from "react-router"; -import styled from "styled-components"; -import bgImg from "./map-bg.jpeg"; - -const snapPoints = [100, 0.5, 1]; -const initialSnap = 0; + AnimatePresence, + interpolate, + motion, + useMotionTemplate, + useScroll, + useTransform, +} from 'motion/react'; +import { useRef, useState } from 'react'; +import { FiChevronLeft, FiSearch } from 'react-icons/fi'; +import { Sheet, type SheetRef } from 'react-modal-sheet'; +import { Link } from 'react-router'; +import styled from 'styled-components'; +import bgImg from './map-bg.jpeg'; + +const snapPoints = [0, 100, 0.5, 1]; +const initialSnap = 1; const lastSnap = snapPoints.length - 1; export function AppleMaps() { - const [sheetRef, setSheetRef] = useState(null); - const inputRef = useRef(null); - const scrollRef = useRef(null); - const [isOpen, setOpen] = useState(true); - const [inputValue, setInputValue] = useState(""); - const [snapPoint, setSnapPoint] = useState(initialSnap); - const close = () => setOpen(false); - const { scrollY } = useScroll({ container: scrollRef }); - const contentBorderColor = useMotionTemplate`rgba(255, 255, 255, ${useTransform(scrollY, [0, 40], [0, 0.1])})`; - - function handleSheetRef(ref: SheetRef | null) { - if (!sheetRef && ref) { - setSheetRef(ref); - } - } - - function handleInputFocus() { - if (snapPoint !== lastSnap) { - sheetRef?.snapTo(lastSnap); - } - } - - return ( - - - {snapPoint < lastSnap && ( - - - - Back - - - )} - - - - - - - - - - - - - setInputValue(e.target.value)} - onFocus={handleInputFocus} - /> - - - - - - state.currentSnap !== lastSnap} - scrollRef={scrollRef} - style={{ borderTopColor: contentBorderColor }} - > - {!!sheetRef && } - - - - {snapPoint === lastSnap && } - - - ); + const [sheetRef, setSheetRef] = useState(null); + const inputRef = useRef(null); + const scrollRef = useRef(null); + const [isOpen, setOpen] = useState(true); + const [inputValue, setInputValue] = useState(''); + const [snapPoint, setSnapPoint] = useState(initialSnap); + const close = () => setOpen(false); + const { scrollY } = useScroll({ container: scrollRef }); + const contentBorderColor = useMotionTemplate`rgba(255, 255, 255, ${useTransform(scrollY, [0, 40], [0, 0.1])})`; + + function handleSheetRef(ref: SheetRef | null) { + if (!sheetRef && ref) { + setSheetRef(ref); + } + } + + function handleInputFocus() { + if (snapPoint !== lastSnap) { + sheetRef?.snapTo(lastSnap); + } + } + + return ( + + + {snapPoint < lastSnap && ( + + + + Back + + + )} + + + + + + + + + + + + + setInputValue(e.target.value)} + onFocus={handleInputFocus} + /> + + + + + + state.currentSnap !== lastSnap} + scrollRef={scrollRef} + style={{ borderTopColor: contentBorderColor }} + > + {!!sheetRef && } + + + + {snapPoint === lastSnap && } + + + ); } function SheetSuggestions({ sheetRef }: { sheetRef: SheetRef }) { - const mix = interpolate([100, 150], [0, 1]); - - const contentOpacity = useTransform(() => { - return mix(sheetRef.yInverted.get()); - }); - - return ( - - Suggestions - - {places.map((place) => ( - - - {place.icon} - - - {place.name} - {place.address} - - - ))} - - - ); + const mix = interpolate([100, 150], [0, 1]); + + const contentOpacity = useTransform(() => { + return mix(sheetRef.yInverted.get()); + }); + + return ( + + Suggestions + + {places.map((place) => ( + + + {place.icon} + + + {place.name} + {place.address} + + + ))} + + + ); } const SheetContainer = styled(Sheet.Container)` @@ -310,109 +310,109 @@ const ResultAddress = styled.span` `; const places = [ - { - id: 1, - name: "Helsinki Central Station", - address: "Kaivokatu 1, 00100 Helsinki", - icon: "๐Ÿ“", - bgColor: "#ffe6e6", - }, - { - id: 2, - name: "Senate Square", - address: "Senaatintori, 00170 Helsinki", - icon: "๐Ÿ›๏ธ", - bgColor: "#f5f5f5", - }, - { - id: 3, - name: "Suomenlinna", - address: "00190 Helsinki", - icon: "๐Ÿฐ", - bgColor: "#f4e6a1", - }, - { - id: 4, - name: "Market Square", - address: "Kauppatori, 00170 Helsinki", - icon: "๐Ÿช", - bgColor: "#ffe4d6", - }, - { - id: 5, - name: "Temppeliaukio Church", - address: "Lutherinkatu 3, 00100 Helsinki", - icon: "โ›ช", - bgColor: "#e6d3c7", - }, - { - id: 6, - name: "Kamppi Center", - address: "Urho Kekkosen katu 1, 00100 Helsinki", - icon: "๐Ÿข", - bgColor: "#e1ecf4", - }, - { - id: 7, - name: "Finnish National Opera", - address: "Helsinginkatu 58, 00260 Helsinki", - icon: "๐ŸŽญ", - bgColor: "#f0e6f0", - }, - { - id: 8, - name: "Ateneum Art Museum", - address: "Kaivokatu 2, 00100 Helsinki", - icon: "๐Ÿ›๏ธ", - bgColor: "#f5f5f5", - }, - { - id: 9, - name: "Esplanadi Park", - address: "Pohjoisesplanadi, 00170 Helsinki", - icon: "๐ŸŒณ", - bgColor: "#e6f4e6", - }, - { - id: 10, - name: "Hietaniemi Beach", - address: "Hietaranta, 00100 Helsinki", - icon: "๐Ÿ–๏ธ", - bgColor: "#fdf4e6", - }, - { - id: 11, - name: "Stockmann Helsinki Center", - address: "Aleksanterinkatu 52, 00100 Helsinki", - icon: "๐Ÿช", - bgColor: "#ffe4d6", - }, - { - id: 12, - name: "Kiasma Museum", - address: "Mannerheiminaukio 2, 00100 Helsinki", - icon: "๐Ÿข", - bgColor: "#e1ecf4", - }, - { - id: 13, - name: "Kaivopuisto Park", - address: "Kaivopuisto, 00140 Helsinki", - icon: "๐ŸŒŠ", - bgColor: "#e6f3ff", - }, - { - id: 14, - name: "Helsinki Cathedral", - address: "Unioninkatu 29, 00170 Helsinki", - icon: "๐Ÿ›๏ธ", - bgColor: "#f5f5f5", - }, - { - id: 15, - name: "Old Market Hall", - address: "Etelรคranta 1, 00130 Helsinki", - icon: "๐Ÿบ", - bgColor: "#faf3e0", - }, + { + id: 1, + name: 'Helsinki Central Station', + address: 'Kaivokatu 1, 00100 Helsinki', + icon: '๐Ÿ“', + bgColor: '#ffe6e6', + }, + { + id: 2, + name: 'Senate Square', + address: 'Senaatintori, 00170 Helsinki', + icon: '๐Ÿ›๏ธ', + bgColor: '#f5f5f5', + }, + { + id: 3, + name: 'Suomenlinna', + address: '00190 Helsinki', + icon: '๐Ÿฐ', + bgColor: '#f4e6a1', + }, + { + id: 4, + name: 'Market Square', + address: 'Kauppatori, 00170 Helsinki', + icon: '๐Ÿช', + bgColor: '#ffe4d6', + }, + { + id: 5, + name: 'Temppeliaukio Church', + address: 'Lutherinkatu 3, 00100 Helsinki', + icon: 'โ›ช', + bgColor: '#e6d3c7', + }, + { + id: 6, + name: 'Kamppi Center', + address: 'Urho Kekkosen katu 1, 00100 Helsinki', + icon: '๐Ÿข', + bgColor: '#e1ecf4', + }, + { + id: 7, + name: 'Finnish National Opera', + address: 'Helsinginkatu 58, 00260 Helsinki', + icon: '๐ŸŽญ', + bgColor: '#f0e6f0', + }, + { + id: 8, + name: 'Ateneum Art Museum', + address: 'Kaivokatu 2, 00100 Helsinki', + icon: '๐Ÿ›๏ธ', + bgColor: '#f5f5f5', + }, + { + id: 9, + name: 'Esplanadi Park', + address: 'Pohjoisesplanadi, 00170 Helsinki', + icon: '๐ŸŒณ', + bgColor: '#e6f4e6', + }, + { + id: 10, + name: 'Hietaniemi Beach', + address: 'Hietaranta, 00100 Helsinki', + icon: '๐Ÿ–๏ธ', + bgColor: '#fdf4e6', + }, + { + id: 11, + name: 'Stockmann Helsinki Center', + address: 'Aleksanterinkatu 52, 00100 Helsinki', + icon: '๐Ÿช', + bgColor: '#ffe4d6', + }, + { + id: 12, + name: 'Kiasma Museum', + address: 'Mannerheiminaukio 2, 00100 Helsinki', + icon: '๐Ÿข', + bgColor: '#e1ecf4', + }, + { + id: 13, + name: 'Kaivopuisto Park', + address: 'Kaivopuisto, 00140 Helsinki', + icon: '๐ŸŒŠ', + bgColor: '#e6f3ff', + }, + { + id: 14, + name: 'Helsinki Cathedral', + address: 'Unioninkatu 29, 00170 Helsinki', + icon: '๐Ÿ›๏ธ', + bgColor: '#f5f5f5', + }, + { + id: 15, + name: 'Old Market Hall', + address: 'Etelรคranta 1, 00130 Helsinki', + icon: '๐Ÿบ', + bgColor: '#faf3e0', + }, ]; diff --git a/example/src/components/apple-music/Album.tsx b/example/src/components/apple-music/Album.tsx index c013c0c..57f526f 100644 --- a/example/src/components/apple-music/Album.tsx +++ b/example/src/components/apple-music/Album.tsx @@ -1,77 +1,77 @@ -import { styled } from "styled-components"; -import { FaPlay, FaForward, FaRandom } from "react-icons/fa"; -import { motion } from "motion/react"; +import { styled } from 'styled-components'; +import { FaPlay, FaForward, FaRandom } from 'react-icons/fa'; +import { motion } from 'motion/react'; -import type { Album as AlbumType } from "./data"; -import { MoreButton } from "./common"; +import type { Album as AlbumType } from './data'; +import { MoreButton } from './common'; export function Album({ - album, - currentSong, - isPlayerOpen, - onSongClick, - onMiniPlayerClick, + album, + currentSong, + isPlayerOpen, + onSongClick, + onMiniPlayerClick, }: { - album: AlbumType; - currentSong: string; - isPlayerOpen: boolean; - onSongClick: (song: string) => void; - onMiniPlayerClick: () => void; + album: AlbumType; + currentSong: string; + isPlayerOpen: boolean; + onSongClick: (song: string) => void; + onMiniPlayerClick: () => void; }) { - return ( - - -
    - - - - -
    - {album.name} - {album.artist} - - {album.genre} · {album.year} - -
    - -
    -
    - - - - - Play - - - - - Shuffle - - - - - {album.songs.map((song, index) => ( - onSongClick(song)}> - {index + 1} -
    {song}
    -
    - ))} -
    -
    - - {!isPlayerOpen && ( - - - - {currentSong} - -
    - - - - )} - - ); + return ( + + +
    + + + + +
    + {album.name} + {album.artist} + + {album.genre} · {album.year} + +
    + +
    +
    + + + + + Play + + + + + Shuffle + + + + + {album.songs.map((song, index) => ( + onSongClick(song)}> + {index + 1} +
    {song}
    +
    + ))} +
    +
    + + {!isPlayerOpen && ( + + + + {currentSong} + +
    + + + + )} + + ); } const Wrapper = styled.div` diff --git a/example/src/components/apple-music/Player.tsx b/example/src/components/apple-music/Player.tsx index e79a085..4c4d791 100644 --- a/example/src/components/apple-music/Player.tsx +++ b/example/src/components/apple-music/Player.tsx @@ -1,71 +1,71 @@ -import { styled } from "styled-components"; +import { styled } from 'styled-components'; import { - FaPlay, - FaForward, - FaBackward, - FaVolumeOff, - FaVolumeUp, - FaListUl, - FaPodcast, - FaFire, -} from "react-icons/fa"; - -import type { Album as AlbumType } from "./data"; -import { MoreButton } from "./common"; + FaPlay, + FaForward, + FaBackward, + FaVolumeOff, + FaVolumeUp, + FaListUl, + FaPodcast, + FaFire, +} from 'react-icons/fa'; + +import type { Album as AlbumType } from './data'; +import { MoreButton } from './common'; export function Player({ song, album }: { song: string; album: AlbumType }) { - return ( - - - - - - - - - - - {song} - {album.artist} - - - - - - - - 0.00 - -4.00 - - - - - console.log("Prev song")}> - - - console.log("Play / Pause")}> - - - console.log("Next song")}> - - - - - - - - - - - - - - - - - - ); + return ( + + + + + + + + + + + {song} + {album.artist} + + + + + + + + 0.00 + -4.00 + + + + + console.log('Prev song')}> + + + console.log('Play / Pause')}> + + + console.log('Next song')}> + + + + + + + + + + + + + + + + + + ); } const Wrapper = styled.div` diff --git a/example/src/components/apple-music/common.tsx b/example/src/components/apple-music/common.tsx index 952df97..a9734cb 100644 --- a/example/src/components/apple-music/common.tsx +++ b/example/src/components/apple-music/common.tsx @@ -1,12 +1,12 @@ -import { styled } from "styled-components"; -import { FaEllipsisH } from "react-icons/fa"; +import { styled } from 'styled-components'; +import { FaEllipsisH } from 'react-icons/fa'; export function MoreButton() { - return ( - - - - ); + return ( + + + + ); } const AlbumMoreButton = styled.div` diff --git a/example/src/components/apple-music/data.ts b/example/src/components/apple-music/data.ts index 392eff6..46a45b4 100644 --- a/example/src/components/apple-music/data.ts +++ b/example/src/components/apple-music/data.ts @@ -1,40 +1,40 @@ export interface Album { - name: string; - year: string; - artist: string; - genre: string; - image: string; - songs: string[]; + name: string; + year: string; + artist: string; + genre: string; + image: string; + songs: string[]; } export const album: Album = { - name: "Gamification Burn Rate", - year: "2020", - artist: "Niche Market", - genre: "Indie Techno", - image: "https://picsum.photos/400", - songs: [ - "Gamification investor seed money", - "Gen-z iPad", - "Bandwidth influencer", - "Paradigm shift", - "Buzz entrepreneur", - "Android disruptive", - "Marketing rockstar", - "Focus", - "Gen-z return", - "Accelerator ownership", - "Termsheet iteration incubator", - "Pivot seed", - ], + name: 'Gamification Burn Rate', + year: '2020', + artist: 'Niche Market', + genre: 'Indie Techno', + image: 'https://picsum.photos/400', + songs: [ + 'Gamification investor seed money', + 'Gen-z iPad', + 'Bandwidth influencer', + 'Paradigm shift', + 'Buzz entrepreneur', + 'Android disruptive', + 'Marketing rockstar', + 'Focus', + 'Gen-z return', + 'Accelerator ownership', + 'Termsheet iteration incubator', + 'Pivot seed', + ], }; export const preloadImages = () => { - const link = document.createElement("link"); - link.rel = "preload"; - link.as = "image"; - link.href = album.image; - document.head.appendChild(link); + const link = document.createElement('link'); + link.rel = 'preload'; + link.as = 'image'; + link.href = album.image; + document.head.appendChild(link); }; // This is for better animation perf on Safari diff --git a/example/src/components/apple-music/index.tsx b/example/src/components/apple-music/index.tsx index 33b2784..f6ce7ec 100644 --- a/example/src/components/apple-music/index.tsx +++ b/example/src/components/apple-music/index.tsx @@ -1,48 +1,48 @@ -import { useState } from "react"; -import { Sheet } from "react-modal-sheet"; -import { styled } from "styled-components"; +import { useState } from 'react'; +import { Sheet } from 'react-modal-sheet'; +import { styled } from 'styled-components'; -import { useMetaThemeColor } from "../hooks"; -import { Album } from "./Album"; -import { Player } from "./Player"; -import { album } from "./data"; +import { useMetaThemeColor } from '../hooks'; +import { Album } from './Album'; +import { Player } from './Player'; +import { album } from './data'; export function AppleMusic() { - const [isPlayerOpen, setPlayerOpen] = useState(false); - // biome-ignore lint/style/noNonNullAssertion: songs are static - const [currentSong, setCurrentSong] = useState(album.songs[0]!); - - const openPlayer = () => setPlayerOpen(true); - const closePlayer = () => setPlayerOpen(false); - - useMetaThemeColor({ when: isPlayerOpen, from: "#111", to: "#000" }); - useMetaThemeColor({ to: "#111" }); - - return ( - <> - - - - - - - - - - - - - ); + const [isPlayerOpen, setPlayerOpen] = useState(false); + // biome-ignore lint/style/noNonNullAssertion: songs are static + const [currentSong, setCurrentSong] = useState(album.songs[0]!); + + const openPlayer = () => setPlayerOpen(true); + const closePlayer = () => setPlayerOpen(false); + + useMetaThemeColor({ when: isPlayerOpen, from: '#111', to: '#000' }); + useMetaThemeColor({ to: '#111' }); + + return ( + <> + + + + + + + + + + + + + ); } const PlayerSheet = styled(Sheet)` diff --git a/example/src/components/common.tsx b/example/src/components/common.tsx index 87790bc..1a72250 100644 --- a/example/src/components/common.tsx +++ b/example/src/components/common.tsx @@ -1,22 +1,22 @@ -import { useRef } from "react"; -import { type AriaButtonProps, useButton } from "react-aria"; -import styled, { createGlobalStyle } from "styled-components"; +import { useRef } from 'react'; +import { type AriaButtonProps, useButton } from 'react-aria'; +import styled, { createGlobalStyle } from 'styled-components'; export function Button({ - children, - className, - ...rest + children, + className, + ...rest }: AriaButtonProps & { - className?: string; + className?: string; }) { - const buttonRef = useRef(null); - const { buttonProps } = useButton(rest, buttonRef); + const buttonRef = useRef(null); + const { buttonProps } = useButton(rest, buttonRef); - return ( - - {children} - - ); + return ( + + {children} + + ); } const ButtonBase = styled.button` diff --git a/example/src/components/hooks.tsx b/example/src/components/hooks.tsx index 91aff9a..68e3947 100644 --- a/example/src/components/hooks.tsx +++ b/example/src/components/hooks.tsx @@ -1,84 +1,27 @@ -import { animate, useMotionValue } from "motion/react"; -import { useEffect, useLayoutEffect, useState } from "react"; +import { useLayoutEffect } from 'react'; export function useMetaThemeColor({ - when = true, - from, - to, + when = true, + from, + to, }: { - when?: boolean; - from?: string; - to: string; + when?: boolean; + from?: string; + to: string; }) { - // biome-ignore lint/correctness/useExhaustiveDependencies: It was here before Biome 2 - useLayoutEffect(() => { - const meta = document.querySelector('meta[name="theme-color"]'); - if (!meta) return; + // biome-ignore lint/correctness/useExhaustiveDependencies: It was here before Biome 2 + useLayoutEffect(() => { + const meta = document.querySelector('meta[name="theme-color"]'); + if (!meta) return; - const current = from || (meta.getAttribute("content") as string); + const current = from || (meta.getAttribute('content') as string); - if (when) { - meta.setAttribute("content", to); + if (when) { + meta.setAttribute('content', to); - return () => { - meta.setAttribute("content", current); - }; - } - }, [when]); -} - -export function useVirtualKeyboard() { - const [isKeyboardOpen, setKeyboardOpen] = useState(false); - const [keyboardHeight, setKeyboardHeight] = useState(0); - - useEffect(() => { - const visualViewport = window.visualViewport; - - if (visualViewport) { - const onResize = () => { - const focusedElement = document.activeElement as HTMLElement | null; - - // Bail if no element is focused as that also means no input is focused - if (!focusedElement) return; - - const isInputFocused = - focusedElement.tagName === "INPUT" || - focusedElement.tagName === "TEXTAREA"; - - // Virtual keyboard should only be visible if an input is focused - if (isInputFocused && visualViewport.height < window.innerHeight) { - setKeyboardOpen(true); - setKeyboardHeight(window.innerHeight - visualViewport.height); - } else if (isKeyboardOpen) { - // Reset keyboard height if it was open - setKeyboardOpen(false); - setKeyboardHeight(0); - } - }; - - visualViewport.addEventListener("resize", onResize); - - return () => { - visualViewport.removeEventListener("resize", onResize); - }; - } - }, [isKeyboardOpen]); - - return { keyboardHeight, isKeyboardOpen }; -} - -export function useAnimatedVirtualKeyboard() { - const { isKeyboardOpen, keyboardHeight } = useVirtualKeyboard(); - const animatedKeyboardHeight = useMotionValue(keyboardHeight); - - // biome-ignore lint/correctness/useExhaustiveDependencies: It was here before Biome 2 - useEffect(() => { - if (isKeyboardOpen) { - animate(animatedKeyboardHeight, keyboardHeight); - } else { - animate(animatedKeyboardHeight, 0); - } - }, [isKeyboardOpen, keyboardHeight]); - - return { keyboardHeight: animatedKeyboardHeight, isKeyboardOpen }; + return () => { + meta.setAttribute('content', current); + }; + } + }, [when]); } diff --git a/example/src/components/slack-message/NewMessageContent.tsx b/example/src/components/slack-message/NewMessageContent.tsx index 5d14f94..6ecf768 100644 --- a/example/src/components/slack-message/NewMessageContent.tsx +++ b/example/src/components/slack-message/NewMessageContent.tsx @@ -1,53 +1,53 @@ -import type { RefObject } from "react"; -import { Sheet } from "react-modal-sheet"; -import { styled } from "styled-components"; +import type { RefObject } from 'react'; +import { Sheet } from 'react-modal-sheet'; +import { styled } from 'styled-components'; const people = [ - "john", - "hannah", - "trevor", - "greg", - "mary", - "gigi", - "kendal", - "mark", - "fiona", - "herman", - "juno", - "beatrice", + 'john', + 'hannah', + 'trevor', + 'greg', + 'mary', + 'gigi', + 'kendal', + 'mark', + 'fiona', + 'herman', + 'juno', + 'beatrice', ].map((name, i) => ({ - id: i, - name, - image: (i: number) => `https://picsum.photos/${i}/200`, + id: i, + name, + image: (i: number) => `https://picsum.photos/${i}/200`, })); export function NewMessageContent({ - inputRef, + inputRef, }: { - inputRef: RefObject; + inputRef: RefObject; }) { - return ( - <> - - To: - - + return ( + <> + + To: + + - - {people.map(({ id, name, image }) => ( - - - {name} - - {name} - - ))} - - - ); + + {people.map(({ id, name, image }) => ( + + + {name} + + {name} + + ))} + + + ); } const Search = styled.label` @@ -110,7 +110,7 @@ const PersonImage = styled.img` `; const PersonName = styled.span<{ dimmed?: boolean }>` - color: ${(p) => (p.dimmed ? "#888" : "#fff")}; + color: ${(p) => (p.dimmed ? '#888' : '#fff')}; font-weight: ${(p) => (p.dimmed ? 400 : 600)}; `; @@ -118,6 +118,6 @@ const PersonStatus = styled.div<{ online?: boolean }>` width: 10px; height: 10px; border-radius: 50%; - border: 1px solid ${(p) => (p.online ? "green" : "#888")}; - background-color: ${(p) => (p.online ? "green" : "transparent")}; + border: 1px solid ${(p) => (p.online ? 'green' : '#888')}; + background-color: ${(p) => (p.online ? 'green' : 'transparent')}; `; diff --git a/example/src/components/slack-message/NewMessageHeader.tsx b/example/src/components/slack-message/NewMessageHeader.tsx index 700bcad..245c342 100644 --- a/example/src/components/slack-message/NewMessageHeader.tsx +++ b/example/src/components/slack-message/NewMessageHeader.tsx @@ -1,30 +1,30 @@ -import { useRef } from "react"; -import { styled } from "styled-components"; -import { FiX as CloseIcon } from "react-icons/fi"; -import { useButton } from "react-aria"; +import { useRef } from 'react'; +import { styled } from 'styled-components'; +import { FiX as CloseIcon } from 'react-icons/fi'; +import { useButton } from 'react-aria'; export function NewMessageHeader({ - sheetState, - titleProps, + sheetState, + titleProps, }: { - sheetState: any; - titleProps: any; + sheetState: any; + titleProps: any; }) { - const ref = useRef(null); - const closeButton = useButton( - { onPress: sheetState.close, "aria-label": "Close bottom sheet" }, - ref, - ); // prettier-ignore + const ref = useRef(null); + const closeButton = useButton( + { onPress: sheetState.close, 'aria-label': 'Close bottom sheet' }, + ref + ); // prettier-ignore - return ( - - - - + return ( + + + + - New Message - - ); + New Message + + ); } const Wrapper = styled.div` diff --git a/example/src/components/slack-message/index.tsx b/example/src/components/slack-message/index.tsx index c3e5f42..082ef81 100644 --- a/example/src/components/slack-message/index.tsx +++ b/example/src/components/slack-message/index.tsx @@ -1,104 +1,104 @@ -import { useRef } from "react"; -import { FiEdit as MessageIcon, FiSearch as SearchIcon } from "react-icons/fi"; -import { Sheet } from "react-modal-sheet"; -import { useOverlayTriggerState } from "react-stately"; -import { styled } from "styled-components"; +import { useRef } from 'react'; +import { FiEdit as MessageIcon, FiSearch as SearchIcon } from 'react-icons/fi'; +import { Sheet } from 'react-modal-sheet'; +import { useOverlayTriggerState } from 'react-stately'; +import { styled } from 'styled-components'; import { - FocusScope, - OverlayProvider, - useButton, - useDialog, - useModal, - useOverlay, -} from "react-aria"; - -import { ScrollView } from "../common"; -import { useMetaThemeColor } from "../hooks"; -import { NewMessageContent } from "./NewMessageContent"; -import { NewMessageHeader } from "./NewMessageHeader"; + FocusScope, + OverlayProvider, + useButton, + useDialog, + useModal, + useOverlay, +} from 'react-aria'; + +import { ScrollView } from '../common'; +import { useMetaThemeColor } from '../hooks'; +import { NewMessageContent } from './NewMessageContent'; +import { NewMessageHeader } from './NewMessageHeader'; // A11y added with React Aria: https://react-spectrum.adobe.com/react-aria/useDialog.html export function SlackMessage() { - const sheetState = useOverlayTriggerState({}); - const inputRef = useRef(null); - const openButtonRef = useRef(null); - const openButton = useButton({ onPress: sheetState.open }, openButtonRef); - const focusInput = () => inputRef.current?.focus(); - - useMetaThemeColor({ when: sheetState.isOpen, from: "#111", to: "#000" }); - useMetaThemeColor({ to: "#111" }); - - return ( - - - - - A11y Workspace - - - - - - - - - - - - - - - - - - - ); + const sheetState = useOverlayTriggerState({}); + const inputRef = useRef(null); + const openButtonRef = useRef(null); + const openButton = useButton({ onPress: sheetState.open }, openButtonRef); + const focusInput = () => inputRef.current?.focus(); + + useMetaThemeColor({ when: sheetState.isOpen, from: '#111', to: '#000' }); + useMetaThemeColor({ to: '#111' }); + + return ( + + + + + A11y Workspace + + + + + + + + + + + + + + + + + + + ); } const MessageSheetComp = ({ - sheetState, - inputRef, + sheetState, + inputRef, }: { - sheetState: any; - inputRef: any; + sheetState: any; + inputRef: any; }) => { - const ref = useRef(null); - const dialog = useDialog({}, ref); - const overlay = useOverlay( - { onClose: sheetState.close, isOpen: true, isDismissable: true }, - ref, - ); - - useModal(); - - // HACK: some props from React Aria need to be cast to `any` - // since they conflict with the Motion props - return ( - <> - - - - - - - - - - ); + const ref = useRef(null); + const dialog = useDialog({}, ref); + const overlay = useOverlay( + { onClose: sheetState.close, isOpen: true, isDismissable: true }, + ref + ); + + useModal(); + + // HACK: some props from React Aria need to be cast to `any` + // since they conflict with the Motion props + return ( + <> + + + + + + + + + + ); }; const Wrapper = styled.div` diff --git a/example/src/index.css b/example/src/index.css index 627a895..13d8851 100644 --- a/example/src/index.css +++ b/example/src/index.css @@ -1,80 +1,80 @@ :root { - --vh: 1vh; + --vh: 1vh; } /* Support the new `dvh` unit so that dynamically shrinking iOS Safari UI works */ @supports (height: 1dvh) { - :root { - --vh: 1dvh; - } + :root { + --vh: 1dvh; + } } /* However when the app is installed as PWA use the `vh` unit to avoid weird layout issues */ @media all and (display-mode: standalone) { - :root { - --vh: 1vh; - } + :root { + --vh: 1vh; + } } html { - background-color: #fff; - height: 100%; + background-color: #fff; + height: 100%; } body { - margin: 0; - font-size: 16px; - font-family: "Inter", sans-serif; - font-optical-sizing: auto; - font-weight: 400; - font-style: normal; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - -webkit-overflow-scrolling: touch; - -webkit-tap-highlight-color: transparent; - -webkit-touch-callout: none; + margin: 0; + font-size: 16px; + font-family: "Inter", sans-serif; + font-optical-sizing: auto; + font-weight: 400; + font-style: normal; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-overflow-scrolling: touch; + -webkit-tap-highlight-color: transparent; + -webkit-touch-callout: none; } #root { - display: flex; - flex-direction: column; - overflow: hidden; - background-color: #000; - height: 100%; - min-height: calc(100 * var(--vh)); + display: flex; + flex-direction: column; + overflow: hidden; + background-color: #000; + height: 100%; + min-height: calc(100 * var(--vh)); } button, input, textarea { - font-size: 16px; - font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", - "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; + font-size: 16px; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", + "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; } button { - border: none; - background: none; - margin: 0; - padding: 0; + border: none; + background: none; + margin: 0; + padding: 0; } * { - box-sizing: border-box; + box-sizing: border-box; } ul { - list-style: none; - padding: 0; - margin: 0; + list-style: none; + padding: 0; + margin: 0; } li { - margin: 0; + margin: 0; } a { - text-decoration: none; - color: inherit; + text-decoration: none; + color: inherit; } diff --git a/example/src/index.tsx b/example/src/index.tsx index e3e37c4..9d22f3f 100644 --- a/example/src/index.tsx +++ b/example/src/index.tsx @@ -1,27 +1,27 @@ -import "./index.css"; +import './index.css'; -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import { HashRouter } from "react-router"; -import { OverlayProvider } from "react-aria"; +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { HashRouter } from 'react-router'; +import { OverlayProvider } from 'react-aria'; -import { App } from "./components/App"; +import { App } from './components/App'; // biome-ignore lint/style/noNonNullAssertion: root is always defined -const root = createRoot(document.getElementById("root")!); +const root = createRoot(document.getElementById('root')!); function Root() { - return ( - - - - - - ); + return ( + + + + + + ); } root.render( - - - , + + + ); diff --git a/src/SheetContent.tsx b/src/SheetContent.tsx index 963ec5f..bc97414 100644 --- a/src/SheetContent.tsx +++ b/src/SheetContent.tsx @@ -16,6 +16,8 @@ export const SheetContent = forwardRef( children, style: styleProp, className = '', + scrollClassName = '', + scrollStyle: scrollStyleProp, scrollRef: scrollRefProp = null, unstyled, ...rest @@ -86,9 +88,9 @@ export const SheetContent = forwardRef( onMeasureDragConstraints={dragConstraints.onMeasure} > {children} diff --git a/src/hooks/use-scroll-position.ts b/src/hooks/use-scroll-position.ts index 730e318..1eb6c55 100644 --- a/src/hooks/use-scroll-position.ts +++ b/src/hooks/use-scroll-position.ts @@ -1,18 +1,62 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; -export function useScrollPosition() { - const ref = useRef(null); +type UseScrollPositionOptions = { + /** + * Debounce delay in ms for scroll event handling. + * @default 32 + */ + debounceDelay?: number; + /** + * Enable or disable the hook entirely. + * @default true + */ + isEnabled?: boolean; +}; + +/** + * Hook to track the scroll position of an element. + * + * The scroll position can be 'top', 'bottom', 'middle', or undefined if the content is not scrollable. + * The hook provides a `scrollRef` callback to assign to the scrollable element. + * + * Note that the scroll position is only updated when the user stops scrolling + * for a short moment (debounced). You can set `debounceDelay` to `0` to disable debouncing entirely. + * + * @param options Configuration options for the hook. + * @returns An object containing the `scrollRef` callback and the current `scrollPosition`. + * + * @example + * ```tsx + * import { useScrollPosition } from 'react-modal-sheet'; + * + * function MyComponent() { + * const { scrollRef, scrollPosition } = useScrollPosition(); + * + * return ( + *
    + *

    Scroll position: {scrollPosition}

    + *
    + * ...long content... + *
    + *
    + * ); + * } + * ``` + */ +export function useScrollPosition(options: UseScrollPositionOptions = {}) { + const { debounceDelay = 32, isEnabled = true } = options; + + const [element, setElement] = useState(null); const [scrollPosition, setScrollPosition] = useState< 'top' | 'bottom' | 'middle' | undefined >(undefined); useEffect(() => { - const element = ref.current; - if (!element) return; + if (!element || !isEnabled) return; let scrollTimeout: number | null = null; - function determineScrollPosition(element: HTMLDivElement) { + function determineScrollPosition(element: HTMLElement) { const { scrollTop, scrollHeight, clientHeight } = element; const isScrollable = scrollHeight > clientHeight; @@ -42,20 +86,30 @@ export function useScrollPosition() { } function onScroll(event: Event) { - if (event.currentTarget instanceof HTMLDivElement) { + if (event.currentTarget instanceof HTMLElement) { const el = event.currentTarget; + if (scrollTimeout) clearTimeout(scrollTimeout); - // Debounce the scroll handler - scrollTimeout = setTimeout(() => determineScrollPosition(el), 32); + + if (debounceDelay === 0) { + determineScrollPosition(el); + } else { + // Debounce the scroll handler + scrollTimeout = setTimeout( + () => determineScrollPosition(el), + debounceDelay + ); + } } } function onTouchStart(event: Event) { - if (event.currentTarget instanceof HTMLDivElement) { + if (event.currentTarget instanceof HTMLElement) { determineScrollPosition(event.currentTarget); } } + // Determine initial scroll position determineScrollPosition(element); element.addEventListener('scroll', onScroll); @@ -66,7 +120,10 @@ export function useScrollPosition() { element.removeEventListener('scroll', onScroll); element.removeEventListener('touchstart', onTouchStart); }; - }, []); + }, [element, isEnabled]); - return { ref, scrollPosition }; + return { + scrollRef: (element: HTMLElement | null) => setElement(element), + scrollPosition, + }; } diff --git a/src/hooks/use-virtual-keyboard.ts b/src/hooks/use-virtual-keyboard.ts index c9e750f..f197842 100644 --- a/src/hooks/use-virtual-keyboard.ts +++ b/src/hooks/use-virtual-keyboard.ts @@ -8,34 +8,68 @@ type VirtualKeyboardState = { type UseVirtualKeyboardOptions = { /** - * Ref to the container element to apply `keyboard-inset-height` CSS variable updates (required) + * Ref to the container element to apply `keyboard-inset-height` CSS variable updates. + * @default document.documentElement */ - containerRef: RefObject; + containerRef?: RefObject; /** - * Enable or disable the hook entirely (default: true) + * Enable or disable the hook entirely. + * @default true */ isEnabled?: boolean; /** - * Minimum pixel height difference to consider the keyboard visible (default: 100px) + * Minimum pixel height difference to consider the keyboard visible. + * @default 100 */ visualViewportThreshold?: number; /** - * Whether to treat contenteditable elements as text inputs (default: true) + * Whether to treat contenteditable elements as text inputs. + * @default true */ includeContentEditable?: boolean; /** - * Delay in ms for debouncing viewport changes (default: 100ms) + * Delay in ms for debouncing viewport changes. + * @default 100 */ debounceDelay?: number; }; -export function useVirtualKeyboard({ - containerRef, - isEnabled = true, - debounceDelay = 100, - includeContentEditable = true, - visualViewportThreshold = 100, -}: UseVirtualKeyboardOptions) { +/** + * A hook that detects virtual keyboard visibility and height. + * It listens to focus events and visual viewport changes to determine + * if a text input is focused and the keyboard is likely visible. + * + * It also sets the `--keyboard-inset-height` CSS variable on the specified container + * (or `:root` by default) to allow for easy styling adjustments when the keyboard is open. + * + * @param options Configuration options for the hook. + * @returns An object containing `isKeyboardOpen` and `keyboardHeight`. + * + * @example + * ```tsx + * import { useVirtualKeyboard } from 'react-modal-sheet'; + * + * function MyComponent() { + * const { isKeyboardOpen, keyboardHeight } = useVirtualKeyboard(); + * + * return ( + *
    + *

    Keyboard is {isKeyboardOpen ? 'open' : 'closed'}

    + *

    Keyboard height: {keyboardHeight}px

    + *
    + * ); + * } + * ``` + */ +export function useVirtualKeyboard(options: UseVirtualKeyboardOptions = {}) { + const { + containerRef, + isEnabled = true, + debounceDelay = 100, + includeContentEditable = true, + visualViewportThreshold = 100, + } = options; + const [state, setState] = useState({ isVisible: false, height: 0, @@ -61,10 +95,17 @@ export function useVirtualKeyboard({ const vk = (navigator as any).virtualKeyboard; function setKeyboardInsetHeightEnv(height: number) { - containerRef.current?.style.setProperty( - '--keyboard-inset-height', - `${height}px` - ); + const element = containerRef?.current || document.documentElement; + + // Virtual Keyboard API is only available in secure context + if (window.isSecureContext) { + element.style.setProperty( + '--keyboard-inset-height', + `env(keyboard-inset-height, ${height}px)` + ); + } else { + element.style.setProperty('--keyboard-inset-height', `${height}px`); + } } function handleFocusIn(e: FocusEvent) { diff --git a/src/index.tsx b/src/index.tsx index 994a762..cdf22f7 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -23,6 +23,9 @@ export const Sheet: SheetCompound = Object.assign(SheetBase, { Backdrop: SheetBackdrop, }); +export { useScrollPosition } from './hooks/use-scroll-position'; +export { useVirtualKeyboard } from './hooks/use-virtual-keyboard'; + // Export types export type { SheetBackdropProps, diff --git a/src/types.tsx b/src/types.tsx index 036ee77..4bd5d4a 100644 --- a/src/types.tsx +++ b/src/types.tsx @@ -1,6 +1,13 @@ import { - type CSSProperties, + type DragHandler, + type EasingDefinition, + type MotionProps, + type MotionValue, + type motion, +} from 'motion/react'; +import { type ComponentPropsWithoutRef, + type CSSProperties, type ForwardRefExoticComponent, type FunctionComponent, type HTMLAttributes, @@ -9,14 +16,6 @@ import { type RefObject, } from 'react'; -import { - type DragHandler, - type EasingDefinition, - type MotionProps, - type MotionValue, - type motion, -} from 'motion/react'; - export type SheetDetent = 'default' | 'full' | 'content'; type CommonProps = { @@ -76,6 +75,8 @@ export type SheetContentProps = MotionCommonProps & disableDrag?: boolean | ((args: SheetStateInfo) => boolean); disableScroll?: boolean | ((args: SheetStateInfo) => boolean); scrollRef?: RefObject; + scrollClassName?: string; + scrollStyle?: CSSProperties; }; export type SheetBackdropProps = MotionCommonProps & CommonProps;