├── src ├── vite-env.d.ts ├── main.tsx ├── app.module.css ├── app.tsx ├── lib │ ├── dialog.module.css │ ├── hooks.ts │ ├── demo.module.css │ ├── demo.tsx │ └── dialog.tsx └── index.css ├── preview.png ├── public ├── favicon.ico └── featured.png ├── .editorconfig ├── vite.config.ts ├── tsconfig.node.json ├── .gitignore ├── .eslintrc.cjs ├── tsconfig.json ├── package.json ├── LICENSE.md ├── index.html ├── README.md └── yarn.lock /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monodyle/hiki/HEAD/preview.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monodyle/hiki/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/featured.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monodyle/hiki/HEAD/public/featured.png -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './app.tsx' 4 | import './index.css' 5 | 6 | ReactDOM.createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hiki", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "framer-motion": "^10.16.1", 14 | "react": "^18.2.0", 15 | "react-aria-components": "^1.0.0-alpha.6", 16 | "react-dom": "^18.2.0" 17 | }, 18 | "devDependencies": { 19 | "@types/react": "^18.2.21", 20 | "@types/react-dom": "^18.2.7", 21 | "@typescript-eslint/eslint-plugin": "^6.5.0", 22 | "@typescript-eslint/parser": "^6.5.0", 23 | "@vitejs/plugin-react": "^4.0.4", 24 | "eslint": "^8.48.0", 25 | "eslint-plugin-react-hooks": "^4.6.0", 26 | "eslint-plugin-react-refresh": "^0.4.3", 27 | "typescript": "^5.2.2", 28 | "vite": "^4.4.9" 29 | }, 30 | "packageManager": "yarn@1.22.19" 31 | } 32 | -------------------------------------------------------------------------------- /src/app.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | margin: 256px auto 48px; 3 | display: flex; 4 | flex-direction: column; 5 | align-items: center; 6 | justify-content: center; 7 | gap: 24px; 8 | } 9 | 10 | .heading { 11 | display: flex; 12 | flex-direction: column; 13 | align-items: center; 14 | gap: 8px; 15 | margin: 0; 16 | } 17 | 18 | .romaji { 19 | font-size: 16px; 20 | line-height: 8px; 21 | font-weight: 500; 22 | color: var(--stone-500); 23 | } 24 | 25 | .japanese { 26 | font-family: "Dela Gothic One", cursive; 27 | font-size: 56px; 28 | font-weight: 600; 29 | line-height: 48px; 30 | } 31 | 32 | .desc { 33 | color: var(--stone-600); 34 | text-align: center; 35 | } 36 | 37 | .footer { 38 | margin-top: auto; 39 | padding: 24px; 40 | text-align: center; 41 | font-size: 12px; 42 | color: var(--stone-500); 43 | } 44 | 45 | .footer a { 46 | display: inline-block; 47 | color: var(--stone-600); 48 | text-decoration: none; 49 | border-bottom: 1px dashed; 50 | } 51 | 52 | .footer a:hover { 53 | color: var(--primary); 54 | } 55 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Monody Le 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /src/app.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./app.module.css"; 2 | import { Demo } from "./lib/demo"; 3 | 4 | function App() { 5 | return ( 6 |
7 |
8 |

9 | /hiki/ 10 | 引き 11 |

12 |
13 | a dialog that turn into 14 |
a drawer on small viewport 15 |
16 | 17 |
18 | 37 |
38 | ); 39 | } 40 | 41 | export default App; 42 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Hiki 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 |
6 | hiki - a dialog will turn into a drawer on small viewport 7 |

8 | 9 | ## Preview 10 | 11 | Preview the example on https://hiki.minhle.space/ 12 | 13 | ## Quick start 14 | 15 | ### Uncontrolled 16 | 17 | ```jsx 18 | import { Dialog } from "@monodyle/hiki"; // not published yet... 19 | 20 | function Application() { 21 | return ( 22 | }> 23 | {({ close }) => ( 24 |
25 | {/* your code goes here */} 26 |
27 | )} 28 |
29 | ) 30 | } 31 | ``` 32 | 33 | ### Controlled 34 | 35 | ```jsx 36 | import { Dialog } from "@monodyle/hiki"; // not published yet... 37 | 38 | function Application() { 39 | const [open, setOpen] = useState(false) 40 | 41 | return ( 42 | setOpen(true)}>Open Dialog}> 43 |
44 | {/* your code goes here */} 45 |
46 |
47 | ) 48 | } 49 | ``` 50 | 51 | ## Development 52 | 53 | ``` 54 | yarn # install dependencies 55 | yarn dev # make it awesome 56 | ``` 57 | 58 | ## Author 59 | 60 | - [@monodyle](https://github.com/monodyle/) - [Twitter](https://twitter.com/monodyle) 61 | 62 | ### References 63 | 64 | Inspired by [Devon Govett](https://twitter.com/devongovett) 65 | 66 | ## License 67 | 68 | MIT © Monody Le 2023+ 69 | -------------------------------------------------------------------------------- /src/lib/dialog.module.css: -------------------------------------------------------------------------------- 1 | .overlay { 2 | position: fixed; 3 | inset: 0; 4 | background: oklch(0% 0 0 / 40%); 5 | backdrop-filter: blur(10px); 6 | display: flex; 7 | flex-direction: column; 8 | z-index: 10; 9 | } 10 | 11 | .modal { 12 | margin: 72px auto 0; 13 | min-width: 256px; 14 | background: var(--stone-100); 15 | border-radius: 12px; 16 | outline: none; 17 | align-self: baseline; 18 | max-height: calc(100% - (72px + 24px)); 19 | overflow: auto; 20 | } 21 | 22 | .affordance { 23 | background-color: var(--stone-400); 24 | margin: 8px auto 0; 25 | width: 48px; 26 | height: 6px; 27 | border-radius: 6px; 28 | } 29 | 30 | .dialog { 31 | position: relative; 32 | padding: 28px; 33 | outline: none; 34 | } 35 | 36 | .close { 37 | display: block; 38 | position: absolute; 39 | cursor: pointer; 40 | top: 12px; 41 | right: 12px; 42 | background-color: transparent; 43 | border: 0 none; 44 | outline: 0 none; 45 | width: 24px; 46 | height: 24px; 47 | padding: 0; 48 | } 49 | 50 | .close::before, 51 | .close::after { 52 | top: 4px; 53 | right: 10px; 54 | position: absolute; 55 | content: " "; 56 | display: block; 57 | height: 16px; 58 | width: 2px; 59 | border-radius: 2px; 60 | background-color: var(--stone-400); 61 | rotate: 45deg; 62 | } 63 | 64 | .close::after { 65 | rotate: -45deg; 66 | } 67 | 68 | .modal.mobile { 69 | min-width: unset; 70 | max-height: initial; 71 | overflow: initial; 72 | width: 100%; 73 | border-bottom-left-radius: 0; 74 | border-bottom-right-radius: 0; 75 | position: relative; 76 | flex: 1; 77 | align-self: flex-end; 78 | } 79 | 80 | .modal.mobile::after { 81 | content: " "; 82 | display: block; 83 | position: absolute; 84 | background-color: inherit; 85 | top: 100%; 86 | left: 0; 87 | right: 0; 88 | height: 100%; 89 | } 90 | 91 | .modal.mobile .dialog { 92 | height: 100%; 93 | } 94 | -------------------------------------------------------------------------------- /src/lib/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | const isBrowser = !!( 4 | typeof window !== "undefined" && 5 | window.document && 6 | window.document.createElement 7 | ); 8 | 9 | type Subscriber = () => void; 10 | 11 | const subscribers = new Set(); 12 | 13 | const responsiveConfig = { 14 | xs: 0, 15 | sm: 576, 16 | md: 768, 17 | lg: 992, 18 | xl: 1200, 19 | } as const; 20 | type ResponsiveConfig = typeof responsiveConfig; 21 | type ResponsiveBreakpoint = keyof ResponsiveConfig; 22 | type ResponsiveInfo = Record; 23 | let info: ResponsiveInfo; 24 | 25 | function handleResize() { 26 | const oldInfo = info; 27 | calculate(); 28 | if (oldInfo === info) return; 29 | for (const subscriber of subscribers) { 30 | subscriber(); 31 | } 32 | } 33 | 34 | let listening = false; 35 | 36 | function calculate() { 37 | const width = window.innerWidth; 38 | const newInfo = {} as ResponsiveInfo; 39 | let shouldUpdate = false; 40 | for (const _key of Object.keys(responsiveConfig)) { 41 | const key = _key as ResponsiveBreakpoint; 42 | newInfo[key] = width >= responsiveConfig[key]; 43 | if (newInfo[key] !== info[key]) { 44 | shouldUpdate = true; 45 | } 46 | } 47 | if (shouldUpdate) { 48 | info = newInfo; 49 | } 50 | } 51 | 52 | export function useResponsive() { 53 | if (isBrowser && !listening) { 54 | info = {} as ResponsiveInfo; 55 | calculate(); 56 | window.addEventListener("resize", handleResize); 57 | listening = true; 58 | } 59 | const [state, setState] = useState(info); 60 | 61 | useEffect(() => { 62 | if (!isBrowser) return; 63 | 64 | // In React 18's StrictMode, useEffect perform twice, resize listener is remove, so handleResize is never perform. 65 | // https://github.com/alibaba/hooks/issues/1910 66 | if (!listening) { 67 | window.addEventListener("resize", handleResize); 68 | } 69 | 70 | const subscriber = () => { 71 | setState(info); 72 | }; 73 | 74 | subscribers.add(subscriber); 75 | return () => { 76 | subscribers.delete(subscriber); 77 | if (subscribers.size === 0) { 78 | window.removeEventListener("resize", handleResize); 79 | listening = false; 80 | } 81 | }; 82 | }, []); 83 | 84 | return state; 85 | } 86 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap"); 2 | @import url("https://fonts.googleapis.com/css2?family=Dela+Gothic+One&display=swap&text=引き"); 3 | 4 | :root { 5 | font-family: "Space Grotesk", sans-serif; 6 | line-height: 1.5; 7 | font-weight: 400; 8 | 9 | font-synthesis: none; 10 | text-rendering: optimizeLegibility; 11 | -webkit-font-smoothing: antialiased; 12 | -moz-osx-font-smoothing: grayscale; 13 | -webkit-text-size-adjust: 100%; 14 | 15 | --stone-50: oklch(98.48% 0.001 106.42); 16 | --stone-100: oklch(96.99% 0.001 106.42); 17 | --stone-200: oklch(92.32% 0.003 48.72); 18 | --stone-300: oklch(86.87% 0.004 56.37); 19 | --stone-400: oklch(71.61% 0.009 56.26); 20 | --stone-500: oklch(55.34% 0.012 58.07); 21 | --stone-600: oklch(44.44% 0.01 73.64); 22 | --stone-700: oklch(37.41% 0.009 67.56); 23 | --stone-800: oklch(26.85% 0.006 34.3); 24 | --stone-900: oklch(21.61% 0.006 56.04); 25 | --stone-950: oklch(14.69% 0.004 49.25); 26 | 27 | --primary: oklch(52.56% 0.26269367484700523 262.9171850512645); 28 | } 29 | 30 | *, *::before, *::after { 31 | box-sizing: border-box; 32 | } 33 | 34 | button, 35 | input { 36 | font-family: "Space Grotesk", sans-serif; 37 | } 38 | 39 | body { 40 | margin: 0; 41 | background-color: var(--stone-100); 42 | color: var(--stone-900); 43 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='429' height='51.5' viewBox='0 0 1000 120'%3E%3Cg fill='none' stroke='%23E7E5E4' stroke-width='1' stroke-opacity='0.77'%3E%3Cpath d='M-500 75c0 0 125-30 250-30S0 75 0 75s125 30 250 30s250-30 250-30s125-30 250-30s250 30 250 30s125 30 250 30s250-30 250-30'/%3E%3Cpath d='M-500 45c0 0 125-30 250-30S0 45 0 45s125 30 250 30s250-30 250-30s125-30 250-30s250 30 250 30s125 30 250 30s250-30 250-30'/%3E%3Cpath d='M-500 105c0 0 125-30 250-30S0 105 0 105s125 30 250 30s250-30 250-30s125-30 250-30s250 30 250 30s125 30 250 30s250-30 250-30'/%3E%3Cpath d='M-500 15c0 0 125-30 250-30S0 15 0 15s125 30 250 30s250-30 250-30s125-30 250-30s250 30 250 30s125 30 250 30s250-30 250-30'/%3E%3Cpath d='M-500-15c0 0 125-30 250-30S0-15 0-15s125 30 250 30s250-30 250-30s125-30 250-30s250 30 250 30s125 30 250 30s250-30 250-30'/%3E%3Cpath d='M-500 135c0 0 125-30 250-30S0 135 0 135s125 30 250 30s250-30 250-30s125-30 250-30s250 30 250 30s125 30 250 30s250-30 250-30'/%3E%3C/g%3E%3C/svg%3E"); 44 | } 45 | 46 | main { 47 | display: flex; 48 | flex-direction: column; 49 | width: 100vw; 50 | height: 100vh; 51 | } 52 | -------------------------------------------------------------------------------- /src/lib/demo.module.css: -------------------------------------------------------------------------------- 1 | .content { 2 | display: flex; 3 | flex-direction: column; 4 | height: 100%; 5 | gap: 24px; 6 | padding-bottom: 12px; 7 | } 8 | 9 | .contentDesktop { 10 | min-width: 360px; 11 | } 12 | 13 | .heading { 14 | margin: 0; 15 | font-weight: 600; 16 | font-size: 18px; 17 | line-height: 1.5; 18 | } 19 | 20 | .button, 21 | .action { 22 | display: block; 23 | padding: 0 24px; 24 | line-height: 36px; 25 | background-color: var(--primary); 26 | border: 0 none; 27 | border-radius: 36px; 28 | color: var(--stone-50); 29 | font-weight: 600; 30 | cursor: pointer; 31 | } 32 | 33 | .action { 34 | width: 100%; 35 | font-weight: 600; 36 | margin-top: auto; 37 | line-height: 48px; 38 | border-radius: 8px; 39 | } 40 | 41 | .group { 42 | display: grid; 43 | grid-template-columns: 3fr 2fr 2fr; 44 | gap: 8px; 45 | } 46 | 47 | .group small { 48 | grid-column: 1 / 4; 49 | color: var(--stone-500); 50 | } 51 | 52 | .field { 53 | display: flex; 54 | flex-direction: column; 55 | position: relative; 56 | gap: 8px; 57 | } 58 | 59 | .field > label, 60 | .field > span { 61 | color: var(--stone-700); 62 | font-weight: 500; 63 | } 64 | 65 | .field .input, 66 | .field .select { 67 | display: block; 68 | width: 100%; 69 | color: var(--stone-900); 70 | background-color: oklch(55.34% 0.012 58.07 / 5%); 71 | border: 1px solid var(--stone-300); 72 | border-radius: 10px; 73 | box-shadow: 0px 8px 16px -10px oklch(21.61% 0.006 56.04 / 12%); 74 | padding: 6px 12px; 75 | line-height: 24px; 76 | } 77 | 78 | .field .select { 79 | display: flex; 80 | justify-content: space-between; 81 | } 82 | 83 | .field .select .chevron { 84 | display: inline-block; 85 | transform: translateY(-4px); 86 | padding-left: 4px; 87 | color: var(--stone-500); 88 | } 89 | 90 | .field .select[data-pressed] { 91 | border-color: var(--stone-300); 92 | } 93 | 94 | .field .inputButton { 95 | position: absolute; 96 | cursor: pointer; 97 | display: block; 98 | bottom: 4px; 99 | right: 4px; 100 | background-color: var(--stone-100); 101 | border: 1px solid var(--stone-300); 102 | line-height: 24px; 103 | border-radius: 6px; 104 | padding: 2px 8px; 105 | box-shadow: 0px 2px 4px -2px oklch(21.61% 0.006 56.04 / 8%), 0px 8px 16px -10px oklch(21.61% 0.006 56.04 / 12%); 106 | } 107 | .field .inputButton:hover { 108 | background-color: var(--stone-200); 109 | } 110 | 111 | .popover { 112 | border: 1px solid var(--stone-200); 113 | min-width: var(--trigger-width); 114 | max-width: 250px; 115 | box-sizing: border-box; 116 | box-shadow: 0 8px 20px rgba(0 0 0 / 0.1); 117 | border-radius: 6px; 118 | background: var(--stone-100); 119 | outline: none; 120 | } 121 | 122 | .listBox { 123 | max-height: inherit; 124 | overflow: auto; 125 | padding: 2px; 126 | outline: none; 127 | } 128 | 129 | .listBox .item { 130 | margin: 2px; 131 | padding: 0.286rem 0.571rem 0.286rem 1.571rem; 132 | border-radius: 6px; 133 | outline: none; 134 | cursor: default; 135 | color: var(--stone-900); 136 | font-size: 1.072rem; 137 | position: relative; 138 | display: flex; 139 | flex-direction: column; 140 | 141 | &[aria-selected="true"] { 142 | font-weight: 600; 143 | 144 | &::before { 145 | content: "✓"; 146 | content: "✓" / ""; 147 | alt: " "; 148 | position: absolute; 149 | top: 4px; 150 | left: 4px; 151 | } 152 | } 153 | 154 | &[data-focused], 155 | &[data-pressed] { 156 | background: var(--stone-200); 157 | } 158 | 159 | [slot="label"] { 160 | font-weight: 600; 161 | } 162 | 163 | [slot="description"] { 164 | font-size: small; 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/lib/demo.tsx: -------------------------------------------------------------------------------- 1 | import { Dialog } from "./dialog"; 2 | 3 | import styles from "./demo.module.css"; 4 | import { 5 | Button, 6 | Input, 7 | Item, 8 | Label, 9 | ListBox, 10 | Popover, 11 | Select, 12 | SelectValue, 13 | TextField, 14 | } from "react-aria-components"; 15 | 16 | const now = new Date(); 17 | now.setSeconds(0); 18 | now.setMinutes(now.getMinutes() + 1); 19 | 20 | export const Demo = () => { 21 | return ( 22 | ( 24 | 27 | )} 28 | isDismissable 29 | > 30 | {({ isSmallViewPort, close }) => ( 31 |
37 |

Create an invite

38 | 39 | 40 | 41 | 42 |
43 | 44 | 45 | 50 | 51 | 52 | 53 | 58 | 59 | 90 | 91 | ✅ This event will take place on the {now.toDateString()} at{" "} 92 | {now.toLocaleTimeString()} 93 | 94 |
95 | 96 | 97 | 98 | 99 | 100 | 103 |
104 | )} 105 |
106 | ); 107 | }; 108 | -------------------------------------------------------------------------------- /src/lib/dialog.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, useState } from "react"; 2 | import { 3 | DialogTriggerProps, 4 | Modal, 5 | ModalOverlay, 6 | Dialog as DialogContent, 7 | DialogTrigger, 8 | } from "react-aria-components"; 9 | import { 10 | AnimatePresence, 11 | ValueAnimationTransition, 12 | animate, 13 | motion, 14 | useMotionTemplate, 15 | useMotionValue, 16 | useTransform, 17 | } from "framer-motion"; 18 | 19 | import styles from "./dialog.module.css"; 20 | import { useResponsive } from "./hooks"; 21 | 22 | const MotionModal = motion(Modal); 23 | const MotionModalOverlay = motion(ModalOverlay); 24 | 25 | const staticTransition = { 26 | duration: 0.5, 27 | ease: [0.32, 0.72, 0, 1], 28 | }; 29 | const inertiaTransition: ValueAnimationTransition = { 30 | type: "inertia", 31 | bounceStiffness: 300, 32 | bounceDamping: 40, 33 | timeConstant: 300, 34 | }; 35 | const largeViewPortOverlayProps = { 36 | initial: { 37 | backgroundColor: "oklch(0% 0 0 / 0%)", 38 | backdropFilter: "blur(0px)", 39 | }, 40 | animate: { 41 | backgroundColor: "oklch(0% 0 0 / 40%)", 42 | backdropFilter: "blur(10px)", 43 | }, 44 | exit: { 45 | backgroundColor: "oklch(0% 0 0 / 0%)", 46 | backdropFilter: "blur(0px)", 47 | }, 48 | }; 49 | const largeViewPortModalProps: Parameters[0] = { 50 | initial: { opacity: 0, translateY: "-100%" }, 51 | animate: { opacity: 1, translateY: "0%" }, 52 | exit: { opacity: 0, translateY: "-100%" }, 53 | transition: { type: "tween", duration: 0.2 }, 54 | }; 55 | 56 | type Props = Omit & { 57 | isDismissable?: boolean; 58 | isKeyboardDismissDisabled?: boolean; 59 | className?: boolean; 60 | target?: 61 | | React.ReactNode 62 | | ((opts: { 63 | open: () => void; 64 | isSmallViewPort?: boolean; 65 | }) => React.ReactNode); 66 | children: 67 | | React.ReactNode 68 | | ((opts: { 69 | close: () => void; 70 | isSmallViewPort?: boolean; 71 | }) => React.ReactNode); 72 | }; 73 | export const Dialog: React.FC = ({ 74 | children, 75 | target, 76 | className, 77 | isDismissable, 78 | isKeyboardDismissDisabled, 79 | ...props 80 | }) => { 81 | const [_isOpen, _setOpen] = useState(false); 82 | const isOpen = props?.isOpen || _isOpen; 83 | const setOpen = props?.onOpenChange || _setOpen; 84 | 85 | const responsive = useResponsive(); 86 | const isSmallViewPort = !responsive.md; 87 | 88 | // Turn Dialog into Drawer on mobile viewport 89 | const h = window.innerHeight; 90 | const y = useMotionValue(h); 91 | 92 | const bgOpacity = useTransform(y, [0, h], [40, 0]); 93 | const bg = useMotionTemplate`oklch(0% 0 0 / ${bgOpacity}%)`; 94 | const backdropBlur = useTransform(y, [0, h], [10, 0]); 95 | const blur = useMotionTemplate`blur(${backdropBlur}px)`; 96 | 97 | const overlayProps: Parameters[0] = isSmallViewPort 98 | ? { 99 | style: { 100 | backgroundColor: bg as unknown as string, 101 | backdropFilter: blur as unknown as string, 102 | }, 103 | } 104 | : largeViewPortOverlayProps; 105 | const modalProps: Parameters[0] = isSmallViewPort 106 | ? { 107 | initial: { y: h }, 108 | animate: { y: 0 }, 109 | exit: { y: h }, 110 | transition: staticTransition, 111 | style: { 112 | y, 113 | top: 0, 114 | }, 115 | drag: "y", 116 | dragElastic: 0.1, 117 | dragConstraints: { top: 0 }, 118 | onDragEnd: (_, { offset, velocity }) => { 119 | if (offset.y > window.innerHeight * 0.75 || velocity.y > 10) { 120 | setOpen(false); 121 | } else { 122 | animate(y, 0, { ...inertiaTransition, min: 0, max: 0 }); 123 | } 124 | }, 125 | } 126 | : largeViewPortModalProps; 127 | 128 | return ( 129 | 130 | {typeof target === "function" 131 | ? target({ open: () => setOpen(true), isSmallViewPort }) 132 | : target} 133 | 134 | {isOpen && ( 135 | 144 | 151 | {isSmallViewPort && ( 152 |
153 | )} 154 | 155 | {({ close }) => ( 156 | 157 | {isDismissable && !isSmallViewPort && ( 158 |