├── .eslintrc ├── .gitignore ├── README.md ├── bun.lockb ├── cosmos.config.json ├── cosmos ├── components │ ├── block-button.fixture.tsx │ ├── cosmos.decorator.tsx │ ├── dialog-content.fixture.tsx │ └── search.fixture.tsx ├── main.tsx ├── mock │ ├── index.tsx │ └── static │ │ ├── accordion.png │ │ ├── contact-person-list.png │ │ ├── dealer-list.png │ │ ├── dealers.png │ │ ├── door-type-list.png │ │ ├── featured-quote.png │ │ ├── people.png │ │ ├── product.png │ │ └── quote.png └── sanity │ ├── content-array-button.fixture.tsx │ ├── cosmos.decorator.tsx │ ├── dialog.fixture.tsx │ └── portable-text-button.fixture.tsx ├── index.html ├── package.json ├── postcss.config.js ├── src ├── components │ ├── block-button.tsx │ ├── block-group.tsx │ ├── block-list.tsx │ ├── dialog-content.tsx │ ├── group-button.tsx │ ├── groups-list.tsx │ ├── provider.tsx │ └── search.tsx ├── index.css ├── index.tsx ├── sanity │ ├── content-array-button.tsx │ ├── dialog.tsx │ ├── portable-text-button.tsx │ └── replacer.tsx ├── types.d.tsx └── utils.tsx ├── static ├── base.png └── search.png ├── tailwind.config.js ├── tsconfig.json └── vite.config.ts /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "plugin:react/recommended", 11 | "plugin:prettier/recommended", 12 | "plugin:tailwindcss/recommended", 13 | "plugin:react-hooks/recommended", 14 | "plugin:jsx-a11y/recommended" 15 | ], 16 | "parser": "@typescript-eslint/parser", 17 | "parserOptions": { 18 | "ecmaFeatures": { 19 | "jsx": true 20 | }, 21 | "ecmaVersion": 12, 22 | "sourceType": "module" 23 | }, 24 | "plugins": [ 25 | "@typescript-eslint", 26 | "prettier", 27 | "react", 28 | "react-hooks", 29 | "tailwindcss", 30 | "simple-import-sort" 31 | ], 32 | "ignorePatterns": [ 33 | "dev-dist/**/*.*", 34 | "dist/**/*.*", 35 | "node_modules/**/*.*", 36 | "**/theme-dark.js", 37 | "**/theme-light.js" 38 | ], 39 | "settings": { 40 | "react": { 41 | "version": "detect" 42 | } 43 | }, 44 | "rules": { 45 | "prettier/prettier": [ 46 | "error", 47 | { 48 | "tabs": true, 49 | "tabWidth": 4, 50 | "bracketSpacing": false, 51 | "endOfLine": "auto", 52 | "printWidth": 100, 53 | "semi": true, 54 | "singleQuote": true 55 | } 56 | ], 57 | "@typescript-eslint/consistent-type-imports": "error", 58 | // disabled tailwindcss/no-custom-classname because it isn't loading the tailwind.config.js file 59 | "tailwindcss/no-custom-classname": "off", 60 | "@typescript-eslint/no-unused-vars": [ 61 | "warn", 62 | { 63 | "varsIgnorePattern": "^_.*$" 64 | } 65 | ], 66 | "simple-import-sort/imports": [ 67 | "error", 68 | { 69 | "groups": [ 70 | // Packages `react` related packages come first. 71 | ["^react", "^@?\\w"], 72 | // Internal packages. 73 | ["^(@|components)(/.*|$)"], 74 | // Side effect imports. 75 | ["^\\u0000"], 76 | // Parent imports. Put `..` last. 77 | ["^\\.\\.(?!/?$)", "^\\.\\./?$"], 78 | // Other relative imports. Put same-folder imports and `.` last. 79 | ["^\\./(?=.*/)(?!/?$)", "^\\.(?!/?$)", "^\\./?$"], 80 | // Style imports. 81 | ["^.+\\.?(css)$"] 82 | ] 83 | } 84 | ], 85 | "simple-import-sort/exports": "error", 86 | "react/react-in-jsx-scope": "off" 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Caches 14 | 15 | .cache 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | 19 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 20 | 21 | # Runtime data 22 | 23 | pids 24 | _.pid 25 | _.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | 34 | coverage 35 | *.lcov 36 | 37 | # nyc test coverage 38 | 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | 43 | .grunt 44 | 45 | # Bower dependency directory (https://bower.io/) 46 | 47 | bower_components 48 | 49 | # node-waf configuration 50 | 51 | .lock-wscript 52 | 53 | # Compiled binary addons (https://nodejs.org/api/addons.html) 54 | 55 | build/Release 56 | 57 | # Dependency directories 58 | 59 | node_modules/ 60 | jspm_packages/ 61 | 62 | # Snowpack dependency directory (https://snowpack.dev/) 63 | 64 | web_modules/ 65 | 66 | # TypeScript cache 67 | 68 | *.tsbuildinfo 69 | 70 | # Optional npm cache directory 71 | 72 | .npm 73 | 74 | # Optional eslint cache 75 | 76 | .eslintcache 77 | 78 | # Optional stylelint cache 79 | 80 | .stylelintcache 81 | 82 | # Microbundle cache 83 | 84 | .rpt2_cache/ 85 | .rts2_cache_cjs/ 86 | .rts2_cache_es/ 87 | .rts2_cache_umd/ 88 | 89 | # Optional REPL history 90 | 91 | .node_repl_history 92 | 93 | # Output of 'npm pack' 94 | 95 | *.tgz 96 | 97 | # Yarn Integrity file 98 | 99 | .yarn-integrity 100 | 101 | # dotenv environment variable files 102 | 103 | .env 104 | .env.development.local 105 | .env.test.local 106 | .env.production.local 107 | .env.local 108 | 109 | # parcel-bundler cache (https://parceljs.org/) 110 | 111 | .parcel-cache 112 | 113 | # Next.js build output 114 | 115 | .next 116 | out 117 | 118 | # Nuxt.js build / generate output 119 | 120 | .nuxt 121 | dist 122 | 123 | # Gatsby files 124 | 125 | # Comment in the public line in if your project uses Gatsby and not Next.js 126 | 127 | # https://nextjs.org/blog/next-9-1#public-directory-support 128 | 129 | # public 130 | 131 | # vuepress build output 132 | 133 | .vuepress/dist 134 | 135 | # vuepress v2.x temp and cache directory 136 | 137 | .temp 138 | 139 | # Docusaurus cache and generated files 140 | 141 | .docusaurus 142 | 143 | # Serverless directories 144 | 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | 149 | .fusebox/ 150 | 151 | # DynamoDB Local files 152 | 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | 161 | .vscode-test 162 | 163 | # yarn v2 164 | 165 | .yarn/cache 166 | .yarn/unplugged 167 | .yarn/build-state.yml 168 | .yarn/install-state.gz 169 | .pnp.* 170 | 171 | # IntelliJ based IDEs 172 | .idea 173 | 174 | # Finder (MacOS) folder config 175 | .DS_Store 176 | 177 | # Cosmos 178 | cosmos-export 179 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @selvklart/sanity-block-selector 2 | 3 | ![NPM Version](https://img.shields.io/npm/v/%40selvklart%2Fsanity-block-selector?link=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F%40selvklart%2Fsanity-block-selector) 4 | 5 | 6 | ## In action 7 | 8 | ![base image](https://github.com/selvklart/sanity-block-selector/raw/main/static/base.png) 9 | ![search image](https://github.com/selvklart/sanity-block-selector/raw/main/static/search.png) 10 | 11 | 12 | ## Description 13 | 14 | Provides a component for overriding the default Sanity block selector - `WithBlockSelector`. 15 | There are two variants, to account for different situations: 16 | `content-array` - for the "Add Item" button at the bottom of content arrays 17 | `portable-text` - for the "..." buttons inside portable text editors 18 | 19 | 20 | ## Use 21 | 22 | The components should be used as custom input components for the fields you want to have this block selector. 23 | 24 | The `blockPreviews` field controls how the block options are rendered. 25 | Each member of the array defines a group, which corresponds to an accordion tab. 26 | Each group has a `title` and `blocks` it contains, where the keys of the `blocks` field correspond to the names of blocks in the `of` array. 27 | Inside each of these, you can set a `description` and a `imageURL` to an image that represents that block (like an icon, or a preview). 28 | Both of these fields are optional, so only the title of the block (as defined in the schema will be shown). 29 | 30 | The `showOther` field can be set to true to create an additional group, which displays all of the blocks defined in the schema that haven't been added to `blockPreviews`. 31 | 32 | The `excludedBlocks` field is an array of the names of the blocks you want to have hidden from the selector. 33 | 34 | With the `text` field, you can override all of the hardcoded text values in the block selector: 35 | - `addItem`: the text in the "Add item" button 36 | - `dialogTitle`: the title of the dialog 37 | - `searchPlaceholder`: the placeholder of the search input in the dialog 38 | - `other`: the name of the `Other` tab 39 | 40 | With the `replaceQueries`field, you can replace the default queries for replacing the built-in block selector buttons in Sanity. By default, the default queries should work, so you shouldn't need to change this. But you have the flexibility to do so if you need to. (This is useful in case Sanity changes the layout of the Studio, and this package doesn't manage to stay up to date with it.). 41 | 42 | These queries depend on the `type` option. 43 | 44 | 45 | ```ts 46 | { 47 | name: 'richPortableText', 48 | title: 'Text', 49 | type: 'array', 50 | of: [...], 51 | components: { 52 | input: WithBlockSelector({ 53 | type: 'portable-text', 54 | blockPreviews: [ 55 | { 56 | title: 'Content', 57 | blocks: { 58 | accordion: { 59 | description: ''. 60 | imageURL: '', 61 | } 62 | } 63 | } 64 | ], 65 | showOther: true, 66 | excludedBlocks: ['extendedBlock'], 67 | text: { 68 | addItem: 'Legg til blokk', 69 | }, 70 | replaceQueries: [ 71 | { 72 | level: 'field', 73 | query: '[data-testid="insert-menu-auto-collapse-menu"] [data-testid="insert-menu-button"]', 74 | }, 75 | { 76 | level: 'document', 77 | query: 'div[data-testid="document-panel-portal"] #menu-button[data-testid="insert-menu-button"]', 78 | }, 79 | ] 80 | }) 81 | } 82 | } 83 | ``` 84 | 85 | 86 | ## Scripts and getting this thing running 87 | 88 | To install dependencies: 89 | 90 | ```bash 91 | bun install 92 | ``` 93 | 94 | To start the react-cosmos documentation: 95 | 96 | ```bash 97 | bun start 98 | ``` 99 | 100 | To build the package: 101 | 102 | ```bash 103 | bun run build 104 | ``` 105 | 106 | To publish the package (this will also build the package automatically): 107 | 108 | ```bash 109 | npm publish --access public 110 | ``` -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/selvklart/sanity-block-selector/07065887f52531475e7c49ff5fb7235bd4ebb18a/bun.lockb -------------------------------------------------------------------------------- /cosmos.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "react-cosmos-plugin-vite", 4 | "react-cosmos-plugin-boolean-input" 5 | ], 6 | "vite": { 7 | "indexPath": "cosmos/main.tsx" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /cosmos/components/block-button.fixture.tsx: -------------------------------------------------------------------------------- 1 | import {BlockButton} from '../../src/components/block-button'; 2 | import type {Block} from '../../src/types.d'; 3 | import {mockGroups} from '../mock'; 4 | 5 | const BlockButtonFixture = (block: Block) => { 6 | return ; 7 | }; 8 | 9 | const block = mockGroups[0].blocks[0]; 10 | export default { 11 | plain: BlockButtonFixture({...block, description: undefined, imageURL: undefined}), 12 | 'with description': BlockButtonFixture({...block, imageURL: undefined}), 13 | 'with image': BlockButtonFixture({...block, description: undefined}), 14 | 'with all': BlockButtonFixture(block), 15 | }; 16 | -------------------------------------------------------------------------------- /cosmos/components/cosmos.decorator.tsx: -------------------------------------------------------------------------------- 1 | import type {PropsWithChildren} from 'react'; 2 | 3 | import {cn} from '../../src/utils'; 4 | 5 | import '../../src/index.css'; 6 | 7 | const ComponentsDecorator = ({children}: PropsWithChildren) => { 8 | return
{children}
; 9 | }; 10 | 11 | export default ComponentsDecorator; 12 | -------------------------------------------------------------------------------- /cosmos/components/dialog-content.fixture.tsx: -------------------------------------------------------------------------------- 1 | import {DialogContent} from '../../src/components/dialog-content'; 2 | import type {Group} from '../../src/types.d'; 3 | import {mockGroups} from '../mock'; 4 | 5 | const DialogContentFixture = (title: string, groups: Group[], filter: string) => { 6 | return ; 7 | }; 8 | 9 | export default { 10 | normal: DialogContentFixture('Select block', mockGroups, ''), 11 | filtered: DialogContentFixture('Select block', mockGroups, 'feature'), 12 | 'no results': DialogContentFixture('Select block', mockGroups, 'empty'), 13 | }; 14 | -------------------------------------------------------------------------------- /cosmos/components/search.fixture.tsx: -------------------------------------------------------------------------------- 1 | import {Search} from '../../src/components/search'; 2 | 3 | const SearchFixture = (value: string, onChange: () => void) => { 4 | return ; 5 | }; 6 | 7 | export default { 8 | empty: SearchFixture('', () => {}), 9 | filled: SearchFixture('featured', () => {}), 10 | }; 11 | -------------------------------------------------------------------------------- /cosmos/main.tsx: -------------------------------------------------------------------------------- 1 | import {createRoot} from 'react-dom/client'; 2 | 3 | const root = createRoot(document.getElementById('root')!); 4 | root.render(

Hello, world!

); 5 | -------------------------------------------------------------------------------- /cosmos/mock/index.tsx: -------------------------------------------------------------------------------- 1 | import type {Group} from '../../src/types.d'; 2 | 3 | export const mockGroups: Group[] = [ 4 | { 5 | title: 'Content', 6 | blocks: [ 7 | { 8 | _key: 'accordion', 9 | name: 'accordion', 10 | title: 'Accordion', 11 | description: 'A list of expandable items', 12 | imageURL: new URL('./static/accordion.png', import.meta.url), 13 | }, 14 | { 15 | _key: 'featured content', 16 | name: 'featured content', 17 | title: 'Featured Content', 18 | description: 'A list of featured content', 19 | imageURL: new URL('./static/article-list.png', import.meta.url), 20 | }, 21 | { 22 | _key: 'featured content grid', 23 | name: 'featured content grid', 24 | title: 'Featured Content Grid', 25 | description: 'A grid of featured content', 26 | imageURL: new URL('./static/article-list-from-feed.png', import.meta.url), 27 | }, 28 | { 29 | _key: 'featured link list', 30 | name: 'featured link list', 31 | title: 'Featured Link List', 32 | description: 'A list of featured links', 33 | imageURL: new URL('./static/article-selection.png', import.meta.url), 34 | }, 35 | { 36 | _key: 'featured quote', 37 | name: 'featured quote', 38 | title: 'Featured Quote', 39 | description: 'A featured quote', 40 | imageURL: new URL('./static/contact-person-list.png', import.meta.url), 41 | }, 42 | { 43 | _key: 'featured quotes', 44 | name: 'featured quotes', 45 | title: 'Featured Quotes', 46 | description: 'A list of featured quotes', 47 | imageURL: new URL('./static/contact-person-selection.png', import.meta.url), 48 | }, 49 | { 50 | _key: 'shared content reference', 51 | name: 'shared content reference', 52 | title: 'Shared Content Reference', 53 | description: 'A list of shared content references', 54 | imageURL: new URL('./static/dealer-list.png', import.meta.url), 55 | }, 56 | { 57 | _key: 'search box', 58 | name: 'search box', 59 | title: 'Search Box', 60 | imageURL: new URL('./static/dealer-map.png', import.meta.url), 61 | }, 62 | { 63 | _key: 'tabbed content', 64 | name: 'tabbed content', 65 | title: 'Tabbed Content', 66 | imageURL: new URL('./static/dealer-selection.png', import.meta.url), 67 | }, 68 | { 69 | _key: 'table', 70 | name: 'table', 71 | title: 'Table', 72 | imageURL: new URL('./static/document-group-block.png', import.meta.url), 73 | }, 74 | ], 75 | }, 76 | { 77 | title: 'Media', 78 | blocks: [ 79 | { 80 | _key: 'images', 81 | name: 'images', 82 | title: 'Images', 83 | description: 'A list of images', 84 | imageURL: new URL('./static/document-group-list.png', import.meta.url), 85 | }, 86 | { 87 | _key: 'images with actions', 88 | name: 'images with actions', 89 | title: 'Images With Actions', 90 | description: 'A list of images with actions', 91 | imageURL: new URL('./static/document-list.png', import.meta.url), 92 | }, 93 | { 94 | _key: 'you-tube video', 95 | name: 'you-tube video', 96 | title: 'YouTube Video', 97 | description: 'A YouTube video', 98 | imageURL: new URL('./static/door-type-list.png', import.meta.url), 99 | }, 100 | { 101 | _key: 'you-tube video selection', 102 | name: 'you-tube video selection', 103 | title: 'YouTube Video Selection', 104 | description: 'A list of YouTube videos with a selection', 105 | imageURL: new URL('./static/featured-content.png', import.meta.url), 106 | }, 107 | ], 108 | }, 109 | { 110 | title: 'Articles', 111 | blocks: [ 112 | { 113 | _key: 'article list', 114 | name: 'article list', 115 | title: 'Article List', 116 | description: 'A list of articles', 117 | imageURL: new URL('./static/featured-content-grid.png', import.meta.url), 118 | }, 119 | { 120 | _key: 'article list from feed', 121 | name: 'article list from feed', 122 | title: 'Article List From Feed', 123 | description: 'A list of articles from a feed', 124 | imageURL: new URL('./static/featured-link-list.png', import.meta.url), 125 | }, 126 | { 127 | _key: 'article selection', 128 | name: 'article selection', 129 | title: 'Article Selection', 130 | description: 'A list of articles with a selection', 131 | imageURL: new URL('./static/featured-quote.png', import.meta.url), 132 | }, 133 | { 134 | _key: 'guide list', 135 | name: 'guide list', 136 | title: 'Guide List', 137 | description: 'A list of guides', 138 | imageURL: new URL('./static/featured-quotes.png', import.meta.url), 139 | }, 140 | { 141 | _key: 'inspirational story list', 142 | name: 'inspirational story list', 143 | title: 'Inspirational Story List', 144 | description: 'A list of inspirational stories', 145 | imageURL: new URL('./static/guide-list.png', import.meta.url), 146 | }, 147 | ], 148 | }, 149 | { 150 | title: 'Products', 151 | blocks: [ 152 | { 153 | _key: 'door type list', 154 | name: 'door type list', 155 | title: 'Door Type List', 156 | description: 'A list of door types', 157 | imageURL: new URL('./static/images.png', import.meta.url), 158 | }, 159 | { 160 | _key: 'window type list', 161 | name: 'window type list', 162 | title: 'Window Type List', 163 | description: 'A list of window types', 164 | imageURL: new URL('./static/images-with-actions.png', import.meta.url), 165 | }, 166 | { 167 | _key: 'product type selection', 168 | name: 'product type selection', 169 | title: 'Product Type Selection', 170 | description: 'A list of product types with a selection', 171 | imageURL: new URL('./static/inspirational-story-list.png', import.meta.url), 172 | }, 173 | { 174 | _key: 'product feature grid', 175 | name: 'product feature grid', 176 | title: 'Product Feature Grid', 177 | description: 'A grid of product features', 178 | imageURL: new URL('./static/interactive-content.png', import.meta.url), 179 | }, 180 | { 181 | _key: 'interactive content', 182 | name: 'interactive content', 183 | title: 'Interactive Content', 184 | description: 'A list of interactive content', 185 | imageURL: new URL( 186 | './static/interactive-content-glass-function.png', 187 | import.meta.url, 188 | ), 189 | }, 190 | { 191 | _key: 'interactive content glass function', 192 | name: 'interactive content glass function', 193 | title: 'Interactive Content Glass Function', 194 | description: 'A list of interactive content glass functions', 195 | imageURL: new URL('./static/interactive-content-glass-option.png', import.meta.url), 196 | }, 197 | { 198 | _key: 'interactive content glass option', 199 | name: 'interactive content glass option', 200 | title: 'Interactive Content Glass Option', 201 | description: 'A list of interactive content glass options', 202 | imageURL: new URL('./static/interactive-content-material.png', import.meta.url), 203 | }, 204 | { 205 | _key: 'interactive content material', 206 | name: 'interactive content material', 207 | title: 'Interactive Content Material', 208 | description: 'A list of interactive content materials', 209 | imageURL: new URL( 210 | './static/interactive-content-muntin-bar-style.png', 211 | import.meta.url, 212 | ), 213 | }, 214 | { 215 | _key: 'interactive content muntin bar style', 216 | name: 'interactive content muntin bar style', 217 | title: 'Interactive Content Muntin Bar Style', 218 | description: 'A list of interactive content muntin bar styles', 219 | imageURL: new URL('./static/interactive-content-profile.png', import.meta.url), 220 | }, 221 | { 222 | _key: 'interactive content profile', 223 | name: 'interactive content profile', 224 | title: 'Interactive Content Profile', 225 | description: 'A list of interactive content profiles', 226 | imageURL: new URL('./static/product-feature-grid.png', import.meta.url), 227 | }, 228 | ], 229 | }, 230 | { 231 | title: 'Documents', 232 | blocks: [ 233 | { 234 | _key: 'document group block', 235 | name: 'document group block', 236 | title: 'Document Group Block', 237 | description: 'A block of document groups', 238 | imageURL: new URL('./static/product-type-selection.png', import.meta.url), 239 | }, 240 | { 241 | _key: 'document group list', 242 | name: 'document group list', 243 | title: 'Document Group List', 244 | description: 'A list of document groups', 245 | imageURL: new URL('./static/search-box.png', import.meta.url), 246 | }, 247 | { 248 | _key: 'document list', 249 | name: 'document list', 250 | title: 'Document List', 251 | description: 'A list of documents', 252 | imageURL: new URL('./static/shared-content-reference.png', import.meta.url), 253 | }, 254 | ], 255 | }, 256 | { 257 | title: 'Dealers', 258 | blocks: [ 259 | { 260 | _key: 'dealer list', 261 | name: 'dealer list', 262 | title: 'Dealer List', 263 | description: 'A list of dealers', 264 | imageURL: new URL('./static/tabbed-content.png', import.meta.url), 265 | }, 266 | { 267 | _key: 'dealer map', 268 | name: 'dealer map', 269 | title: 'Dealer Map', 270 | description: 'A map of dealers', 271 | imageURL: new URL('./static/table.png', import.meta.url), 272 | }, 273 | { 274 | _key: 'dealer selection', 275 | name: 'dealer selection', 276 | title: 'Dealer Selection', 277 | description: 'A list of dealers with a selection', 278 | imageURL: new URL('./static/vacancy-list.png', import.meta.url), 279 | }, 280 | ], 281 | }, 282 | { 283 | title: 'People and positions', 284 | blocks: [ 285 | { 286 | _key: 'contact person list', 287 | name: 'contact person list', 288 | title: 'Contact Person List', 289 | description: 'A list of contact persons', 290 | imageURL: new URL('./static/window-type-list.png', import.meta.url), 291 | }, 292 | { 293 | _key: 'contact person selection', 294 | name: 'contact person selection', 295 | title: 'Contact Person Selection', 296 | description: 'A list of contact persons with a selection', 297 | imageURL: new URL('./static/you-tube-video.png', import.meta.url), 298 | }, 299 | { 300 | _key: 'vacancy list', 301 | name: 'vacancy list', 302 | title: 'Vacancy List', 303 | description: 'A list of vacancies', 304 | imageURL: new URL('./static/you-tube-video-selection.png', import.meta.url), 305 | }, 306 | ], 307 | }, 308 | ]; 309 | -------------------------------------------------------------------------------- /cosmos/mock/static/accordion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/selvklart/sanity-block-selector/07065887f52531475e7c49ff5fb7235bd4ebb18a/cosmos/mock/static/accordion.png -------------------------------------------------------------------------------- /cosmos/mock/static/contact-person-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/selvklart/sanity-block-selector/07065887f52531475e7c49ff5fb7235bd4ebb18a/cosmos/mock/static/contact-person-list.png -------------------------------------------------------------------------------- /cosmos/mock/static/dealer-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/selvklart/sanity-block-selector/07065887f52531475e7c49ff5fb7235bd4ebb18a/cosmos/mock/static/dealer-list.png -------------------------------------------------------------------------------- /cosmos/mock/static/dealers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/selvklart/sanity-block-selector/07065887f52531475e7c49ff5fb7235bd4ebb18a/cosmos/mock/static/dealers.png -------------------------------------------------------------------------------- /cosmos/mock/static/door-type-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/selvklart/sanity-block-selector/07065887f52531475e7c49ff5fb7235bd4ebb18a/cosmos/mock/static/door-type-list.png -------------------------------------------------------------------------------- /cosmos/mock/static/featured-quote.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/selvklart/sanity-block-selector/07065887f52531475e7c49ff5fb7235bd4ebb18a/cosmos/mock/static/featured-quote.png -------------------------------------------------------------------------------- /cosmos/mock/static/people.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/selvklart/sanity-block-selector/07065887f52531475e7c49ff5fb7235bd4ebb18a/cosmos/mock/static/people.png -------------------------------------------------------------------------------- /cosmos/mock/static/product.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/selvklart/sanity-block-selector/07065887f52531475e7c49ff5fb7235bd4ebb18a/cosmos/mock/static/product.png -------------------------------------------------------------------------------- /cosmos/mock/static/quote.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/selvklart/sanity-block-selector/07065887f52531475e7c49ff5fb7235bd4ebb18a/cosmos/mock/static/quote.png -------------------------------------------------------------------------------- /cosmos/sanity/content-array-button.fixture.tsx: -------------------------------------------------------------------------------- 1 | import {useFixtureInput} from 'react-cosmos/client'; 2 | 3 | import {ContentArrayButton} from '../../src/sanity/content-array-button'; 4 | import {mockGroups} from '../mock'; 5 | 6 | const ContentArrayButtonFixture = () => { 7 | const [groups] = useFixtureInput('groups', mockGroups); 8 | const [open, setOpen] = useFixtureInput('open', false); 9 | return ; 10 | }; 11 | 12 | export default ContentArrayButtonFixture; 13 | -------------------------------------------------------------------------------- /cosmos/sanity/cosmos.decorator.tsx: -------------------------------------------------------------------------------- 1 | import type {PropsWithChildren} from 'react'; 2 | import {ThemeProvider} from '@sanity/ui'; 3 | import {buildTheme} from '@sanity/ui/theme'; 4 | 5 | import {cn} from '../../src/utils'; 6 | 7 | import '../../src/index.css'; 8 | 9 | const ComponentsDecorator = ({children}: PropsWithChildren) => { 10 | const theme = buildTheme(); 11 | return ( 12 | 13 |
{children}
14 |
15 | ); 16 | }; 17 | 18 | export default ComponentsDecorator; 19 | -------------------------------------------------------------------------------- /cosmos/sanity/dialog.fixture.tsx: -------------------------------------------------------------------------------- 1 | import {Dialog} from '../../src/sanity/dialog'; 2 | import type {Group} from '../../src/types.d'; 3 | import {mockGroups} from '../mock'; 4 | 5 | const DialogFixture = (groups: Group[]) => { 6 | return {}} />; 7 | }; 8 | 9 | export default DialogFixture(mockGroups); 10 | -------------------------------------------------------------------------------- /cosmos/sanity/portable-text-button.fixture.tsx: -------------------------------------------------------------------------------- 1 | import {useFixtureInput} from 'react-cosmos/client'; 2 | 3 | import {PortableTextButton} from '../../src/sanity/portable-text-button'; 4 | import {mockGroups} from '../mock'; 5 | 6 | const PortableTextButtonFixture = () => { 7 | const [groups] = useFixtureInput('groups', mockGroups); 8 | const [open, setOpen] = useFixtureInput('open', false); 9 | return ; 10 | }; 11 | 12 | export default PortableTextButtonFixture; 13 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Sanity Block Selector 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@selvklart/sanity-block-selector", 3 | "version": "4.0.3", 4 | "type": "module", 5 | "source": "src/index.tsx", 6 | "main": "dist/main.js", 7 | "types": "dist/types.d.ts", 8 | "description": "A custom block selector for Sanity", 9 | "repository": "https://github.com/selvklart/sanity-block-selector", 10 | "scripts": { 11 | "start": "cosmos", 12 | "export": "cosmos-export", 13 | "build": "parcel build --no-cache", 14 | "build:watch": "parcel watch --no-cache", 15 | "prepublishOnly": "bun run build" 16 | }, 17 | "files": [ 18 | "dist" 19 | ], 20 | "author": "Bruno Santos ", 21 | "license": "ISC", 22 | "dependencies": { 23 | "@headlessui/react": "^1.7.18", 24 | "@heroicons/react": "^2.1.1", 25 | "@sanity/icons": "^2.11.2", 26 | "@sanity/ui": "^2.0.10", 27 | "@vitejs/plugin-react": "4.2.1", 28 | "clsx": "^2.1.0", 29 | "framer-motion": "^11.0.13", 30 | "react": "18.2.0", 31 | "react-cosmos": "^6.1.1", 32 | "react-cosmos-plugin-vite": "^6.1.1", 33 | "react-dom": "18.2.0", 34 | "react-router-dom": "^6.22.3", 35 | "sanity": "^3.33.0", 36 | "tailwind-merge": "^2.2.1", 37 | "uuid": "^9.0.1", 38 | "vite": "5.1.4" 39 | }, 40 | "peerDependencies": { 41 | "typescript": "^5.0.0" 42 | }, 43 | "devDependencies": { 44 | "@parcel/packager-ts": "^2.12.0", 45 | "@parcel/transformer-typescript-types": "^2.12.0", 46 | "@tailwindcss/forms": "^0.5.7", 47 | "@types/react": "^18.2.65", 48 | "@types/react-dom": "^18.2.22", 49 | "@typescript-eslint/eslint-plugin": "^7.1.0", 50 | "@typescript-eslint/parser": "^7.1.0", 51 | "autoprefixer": "^10.4.18", 52 | "bun-types": "latest", 53 | "eslint": "^8.56.0", 54 | "eslint-config-next": "14.1.0", 55 | "eslint-config-prettier": "^9.1.0", 56 | "eslint-plugin-jsx-a11y": "^6.8.0", 57 | "eslint-plugin-prettier": "^5.1.3", 58 | "eslint-plugin-react": "^7.33.2", 59 | "eslint-plugin-react-hooks": "^4.6.0", 60 | "eslint-plugin-simple-import-sort": "^12.0.0", 61 | "eslint-plugin-tailwindcss": "^3.14.1", 62 | "parcel": "^2.12.0", 63 | "postcss": "^8.4.35", 64 | "prettier": "^3.2.5", 65 | "react-cosmos-plugin-boolean-input": "^6.1.1", 66 | "tailwindcss": "^3.4.1" 67 | } 68 | } -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /src/components/block-button.tsx: -------------------------------------------------------------------------------- 1 | import {useContext, useEffect, useState} from 'react'; 2 | 3 | import type {Block} from '../types.d'; 4 | import {cn} from '../utils'; 5 | 6 | import {BlockSelectorContext} from './provider'; 7 | 8 | interface Props { 9 | block: Block; 10 | } 11 | 12 | export const BlockButton = ({block}: Props) => { 13 | const {title, description, imageURL} = block; 14 | const [isValid, setIsValid] = useState(false); 15 | const {onSelectBlock} = useContext(BlockSelectorContext); 16 | 17 | useEffect(() => { 18 | if (imageURL) { 19 | const img = new Image(); 20 | img.src = imageURL.href; 21 | img.onload = () => setIsValid(true); 22 | img.onerror = () => setIsValid(false); 23 | } 24 | }, [imageURL]); 25 | 26 | return ( 27 | 66 | ); 67 | }; 68 | -------------------------------------------------------------------------------- /src/components/block-group.tsx: -------------------------------------------------------------------------------- 1 | import type {Block} from '../types.d'; 2 | import {cn} from '../utils'; 3 | 4 | import {BlockButton} from './block-button'; 5 | 6 | interface Props { 7 | blocks?: Block[]; 8 | } 9 | 10 | export const BlockGroup = ({blocks}: Props) => { 11 | if (!blocks || blocks.length === 0) { 12 | return null; 13 | } 14 | 15 | return ( 16 |
17 | {blocks.map((block) => ( 18 | 19 | ))} 20 |
21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /src/components/block-list.tsx: -------------------------------------------------------------------------------- 1 | import {useContext, useEffect, useState} from 'react'; 2 | 3 | import type {Block, Group} from '../types.d'; 4 | import {cn, filterBlockByString} from '../utils'; 5 | 6 | import {BlockGroup} from './block-group'; 7 | import {BlockSelectorContext} from './provider'; 8 | 9 | interface Props { 10 | groups: Group[]; 11 | filter: string; 12 | activeGroup: Group | null; 13 | } 14 | 15 | export const BlockList = ({groups, filter = '', activeGroup}: Props) => { 16 | const {textOptions} = useContext(BlockSelectorContext); 17 | const [blocks, setBlocks] = useState<{ 18 | withImage: Block[]; 19 | withoutImage: Block[]; 20 | }>(); 21 | 22 | useEffect(() => { 23 | const relevantBlocks = groups 24 | .filter((group) => activeGroup === null || activeGroup.title === group.title) 25 | .flatMap((group) => group.blocks) 26 | .filter((block) => filterBlockByString(block, filter)); 27 | const groupedBlocks = groupBlocksByType(relevantBlocks); 28 | setBlocks(groupedBlocks); 29 | }, [groups, filter, activeGroup]); 30 | 31 | if ((blocks?.withImage.length ?? 0) + (blocks?.withoutImage.length ?? 0) === 0) { 32 | return ( 33 |
{textOptions?.noResults ?? 'No results'}
34 | ); 35 | } 36 | 37 | return ( 38 |
    39 | 40 | 41 |
42 | ); 43 | }; 44 | 45 | const groupBlocksByType = (blocks: Block[]) => { 46 | const withImage = blocks 47 | .filter((block) => block.imageURL) 48 | .sort((a, b) => a.title.localeCompare(b.title)); 49 | const withoutImage = blocks 50 | .filter((block) => !block.imageURL) 51 | .sort((a, b) => a.title.localeCompare(b.title)); 52 | 53 | return {withImage, withoutImage}; 54 | }; 55 | -------------------------------------------------------------------------------- /src/components/dialog-content.tsx: -------------------------------------------------------------------------------- 1 | import {useEffect, useState} from 'react'; 2 | 3 | import type {Group} from '../types.d'; 4 | import {cn} from '../utils'; 5 | 6 | import {BlockList} from './block-list'; 7 | import {GroupsList} from './groups-list'; 8 | import {Search} from './search'; 9 | 10 | interface Props { 11 | title: string; 12 | groups: Group[]; 13 | filter?: string; 14 | } 15 | 16 | export const DialogContent = ({title, groups, filter = ''}: Props) => { 17 | const [activeGroup, setActiveGroup] = useState(null); 18 | const [value, setValue] = useState(filter); 19 | const [ref, setRef] = useState(null); 20 | 21 | useEffect(() => { 22 | const card = ref?.closest('[data-ui="Card"]') as HTMLDivElement | null; 23 | card?.style.setProperty('height', '80%'); 24 | }, [ref]); 25 | 26 | return ( 27 |
28 |
29 | 30 | 36 |
37 |
38 |
48 |
59 |
60 | {title} 61 |
62 |
63 | 64 |
65 |
66 |
67 | ); 68 | }; 69 | -------------------------------------------------------------------------------- /src/components/group-button.tsx: -------------------------------------------------------------------------------- 1 | import {useMemo} from 'react'; 2 | 3 | import type {Group} from '../types.d'; 4 | import {cn, filterBlockByString} from '../utils'; 5 | 6 | interface GroupButtonProps { 7 | group: Group; 8 | filter: string; 9 | activeGroup: Group | null; 10 | setActiveGroup: (group: Group | null) => void; 11 | } 12 | 13 | export const GroupButton = ({ 14 | group, 15 | filter = '', 16 | activeGroup, 17 | setActiveGroup, 18 | }: GroupButtonProps) => { 19 | const {title, blocks} = group; 20 | 21 | const blockCount = useMemo(() => { 22 | return blocks.filter((block) => filterBlockByString(block, filter)).length; 23 | }, [filter, blocks]); 24 | 25 | return ( 26 | 89 | ); 90 | }; 91 | -------------------------------------------------------------------------------- /src/components/groups-list.tsx: -------------------------------------------------------------------------------- 1 | import type {Group} from '../types.d'; 2 | import {cn} from '../utils'; 3 | 4 | import {GroupButton, GroupButtonAll} from './group-button'; 5 | 6 | interface Props { 7 | groups: Group[]; 8 | filter: string; 9 | activeGroup: Group | null; 10 | setActiveGroup: (group: Group | null) => void; 11 | } 12 | 13 | export const GroupsList = ({groups, filter = '', activeGroup, setActiveGroup}: Props) => { 14 | return ( 15 |
    16 | 23 | {groups.map((group) => ( 24 | 31 | ))} 32 |
33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /src/components/provider.tsx: -------------------------------------------------------------------------------- 1 | import type {ReactNode} from 'react'; 2 | import {createContext} from 'react'; 3 | 4 | import type {OnBlockSelectFn, TextOptions} from '../types.d'; 5 | 6 | interface Data { 7 | textOptions?: TextOptions; 8 | onSelectBlock: OnBlockSelectFn; 9 | } 10 | 11 | export const BlockSelectorContext = createContext({ 12 | textOptions: undefined, 13 | onSelectBlock: () => {}, 14 | }); 15 | 16 | interface Props { 17 | textOptions?: TextOptions; 18 | onSelectBlock: OnBlockSelectFn; 19 | children?: ReactNode; 20 | } 21 | 22 | export const BlockSelectorContextProvider = ({textOptions, onSelectBlock, children}: Props) => { 23 | return ( 24 | 30 | {children} 31 | 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /src/components/search.tsx: -------------------------------------------------------------------------------- 1 | import {type Dispatch, type SetStateAction, useContext} from 'react'; 2 | import {MagnifyingGlassIcon} from '@heroicons/react/20/solid'; 3 | 4 | import {cn} from '../utils'; 5 | 6 | import {BlockSelectorContext} from './provider'; 7 | 8 | interface Props { 9 | value: string; 10 | onChange: Dispatch>; 11 | } 12 | 13 | export const Search = ({value, onChange}: Props) => { 14 | const {textOptions} = useContext(BlockSelectorContext); 15 | return ( 16 |
17 |
28 |
33 | onChange(e.target.value)} 58 | /> 59 |
60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .block-selector [data-ui="DialogCard"] > [data-ui="Card"] > [data-ui="Flex"] { 6 | @apply static; 7 | @apply items-end; 8 | } 9 | 10 | .block-selector [data-ui="DialogCard"] > [data-ui="Card"] > [data-ui="Flex"] > [data-ui="Box"]:first-child { 11 | @apply bg-transparent; 12 | @apply w-16; 13 | } 14 | 15 | .block-selector [data-ui="DialogCard"] > [data-ui="Card"] > [data-ui="Flex"] > [data-ui="Box"]:first-child [data-ui="Button"] { 16 | @apply bg-transparent; 17 | } 18 | 19 | .block-selector [data-ui="DialogCard"] > [data-ui="Card"] > [data-ui="Flex"] > [data-ui="Box"]:nth-child(2) { 20 | @apply -mt-[49px]; 21 | @apply w-full; 22 | } -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import {useCallback, useEffect, useId, useMemo, useState} from 'react'; 2 | import {ThemeProvider} from '@sanity/ui'; 3 | import {buildTheme} from '@sanity/ui/theme'; 4 | import {type PortableTextInputProps, type SchemaTypeDefinition, set} from 'sanity'; 5 | import {v4 as uuid} from 'uuid'; 6 | 7 | import {BlockSelectorContextProvider} from './components/provider'; 8 | import {ContentArrayButton} from './sanity/content-array-button'; 9 | import {PortableTextButton} from './sanity/portable-text-button'; 10 | import {Replacer} from './sanity/replacer'; 11 | import type {Block, Options, ReplaceQuery} from './types.d'; 12 | import {cn, schemaAndOptionsToGroups} from './utils'; 13 | 14 | import './index.css'; 15 | 16 | const portableTextReplaceQueries: ReplaceQuery[] = [ 17 | { 18 | level: 'field', 19 | query: '[data-testid="insert-menu-auto-collapse-menu"] [data-testid="insert-menu-button"]', 20 | }, 21 | { 22 | level: 'document', 23 | query: 'div[data-testid="document-panel-portal"] #menu-button[data-testid="insert-menu-button"]', 24 | }, 25 | ]; 26 | 27 | const contentArrayReplaceQueries: ReplaceQuery[] = [ 28 | {level: 'field', query: '& > [data-ui="Stack"] > [data-ui="Grid"] > [data-ui="MenuButton"]'}, 29 | ]; 30 | 31 | export const WithBlockSelector = (options: Options) => 32 | function BlockSelector(props: PortableTextInputProps) { 33 | const id = useId(); 34 | const {renderDefault} = props; 35 | const theme = buildTheme(); 36 | const [container, setContainer] = useState(null); 37 | 38 | const hideQueries = 39 | options.replaceQueries ?? 40 | (options.type === 'portable-text' 41 | ? portableTextReplaceQueries 42 | : contentArrayReplaceQueries); 43 | 44 | return ( 45 | 46 |
47 | {renderDefault(props)} 48 | {hideQueries.map(({level, query}) => ( 49 | } 54 | /> 55 | ))} 56 |
57 |
58 | ); 59 | }; 60 | 61 | const Render = (props: PortableTextInputProps & {options: Options}) => { 62 | const [open, setOpen] = useState(false); 63 | 64 | const schema = props.schemaType as unknown as {of: SchemaTypeDefinition[]}; 65 | const schemaDefinitions = schema.of.filter( 66 | (block) => !props.options.excludedBlocks?.includes(block.name), 67 | ); 68 | const [focusedBlock, setFocusedBlock] = useState(null); 69 | 70 | // When a block is selected, add it to the Sanity field value. 71 | const onSelectBlock = useCallback( 72 | (block: Block) => { 73 | const focusedIndex = props.value?.findIndex((block) => block._key === focusedBlock); 74 | const newPortableText = [...(props.value ?? [])]; 75 | if (focusedIndex !== undefined && focusedIndex !== -1) { 76 | newPortableText.splice(focusedIndex + 1, 0, { 77 | _key: uuid(), 78 | _type: block.name, 79 | ...(block.initialValue ?? {}), 80 | }); 81 | } else { 82 | newPortableText.push({ 83 | _key: uuid(), 84 | _type: block.name, 85 | ...(block.initialValue ?? {}), 86 | }); 87 | } 88 | props.onChange(set(newPortableText)); 89 | setOpen(false); 90 | }, 91 | [props, focusedBlock], 92 | ); 93 | 94 | // Keep track of the focused block, so that we can insert the new block after it. 95 | // If the type is content-array, we don't need to keep track of the focused block, 96 | // and the new block is always added to the end of the array. 97 | useEffect(() => { 98 | if (props.options.type === 'content-array') { 99 | return; 100 | } 101 | 102 | const block = props.focusPath.at(0); 103 | if ( 104 | typeof block !== 'string' && 105 | typeof block !== 'undefined' && 106 | typeof block !== 'number' && 107 | !Array.isArray(block) 108 | ) { 109 | setFocusedBlock(block._key); 110 | } 111 | }, [props.focusPath, props.options.type]); 112 | 113 | // Create groups from the schema definitions and the options. 114 | const groups = useMemo( 115 | () => schemaAndOptionsToGroups(schemaDefinitions, props.options), 116 | [schemaDefinitions, props.options], 117 | ); 118 | 119 | return ( 120 | 124 | {props.options.type === 'content-array' ? ( 125 | 126 | ) : ( 127 | 128 | )} 129 | 130 | ); 131 | }; 132 | 133 | export default WithBlockSelector; 134 | -------------------------------------------------------------------------------- /src/sanity/content-array-button.tsx: -------------------------------------------------------------------------------- 1 | import {type Dispatch, type SetStateAction, useContext} from 'react'; 2 | import {AddIcon} from '@sanity/icons'; 3 | import {Button} from '@sanity/ui'; 4 | 5 | import {BlockSelectorContext} from '../components/provider'; 6 | import type {Group} from '../types.d'; 7 | 8 | import {Dialog} from './dialog'; 9 | 10 | interface Props { 11 | groups: Group[]; 12 | open: boolean; 13 | setOpen: Dispatch>; 14 | } 15 | 16 | export const ContentArrayButton = ({groups, open, setOpen}: Props) => { 17 | const {textOptions} = useContext(BlockSelectorContext); 18 | return ( 19 | <> 20 | 21 | {open && setOpen(false)} />} 22 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /src/sanity/replacer.tsx: -------------------------------------------------------------------------------- 1 | import type {ReactNode} from 'react'; 2 | import {useEffect, useState} from 'react'; 3 | import {createPortal} from 'react-dom'; 4 | 5 | interface Props { 6 | root: HTMLElement | Document | null; 7 | hideQuery: string; 8 | replacementNode: ReactNode; 9 | } 10 | 11 | export const Replacer = ({root, hideQuery, replacementNode}: Props) => { 12 | const [parent, setParent] = useState(null); 13 | useObservers(root, hideQuery, setParent); 14 | 15 | if (!parent) { 16 | return null; 17 | } 18 | 19 | return createPortal(replacementNode, parent); 20 | }; 21 | 22 | // These observers make sure that the built-in block selector button is hidden, 23 | // and replaced with our custom block selector button. 24 | const useObservers = ( 25 | container: HTMLElement | Document | null, 26 | hideQuery: string, 27 | setParent: (value: HTMLElement | null) => void, 28 | ) => { 29 | // Observer for inline editor 30 | useEffect(() => { 31 | const observeForButton = () => { 32 | const replaced = container?.querySelector(hideQuery); 33 | if (replaced) { 34 | (replaced as HTMLButtonElement).style.display = 'none'; 35 | } else { 36 | console.warn('Could not find element that matches the query. Skipping.', hideQuery); 37 | } 38 | const portalContainer = replaced?.parentElement; 39 | setParent(portalContainer ?? null); 40 | }; 41 | 42 | const observer = new MutationObserver((mutations) => { 43 | mutations.forEach(observeForButton); 44 | }); 45 | if (container) { 46 | observer.observe(container, {childList: true, subtree: true}); 47 | observeForButton(); 48 | } 49 | return () => { 50 | observer.disconnect(); 51 | }; 52 | }, [container, hideQuery, setParent]); 53 | }; 54 | -------------------------------------------------------------------------------- /src/types.d.tsx: -------------------------------------------------------------------------------- 1 | export type Group = { 2 | title: string; 3 | blocks: Block[]; 4 | defaultOpen?: boolean; 5 | }; 6 | 7 | export type Block = { 8 | _key: string; 9 | name: string; 10 | title: string; 11 | description?: string; 12 | imageURL?: URL; 13 | initialValue?: InitialValue; 14 | }; 15 | 16 | export type InitialValue = Record | undefined | []; 17 | 18 | export type Options = { 19 | /** 20 | * The type of the field that the block selector is used in. 21 | * portable-text corresponds to the textarea fields in Sanity, replacing the ellipsis button. 22 | * content-array corresponds to the array fields in Sanity, replacing the "+Add item" button. 23 | */ 24 | type: 'portable-text' | 'content-array'; 25 | 26 | /** 27 | * This field contains the necessary information to render each block, inside the block selector. 28 | * Each item in the array is a group of blocks, with a title and a list of blocks. 29 | * A group corresponds to an accordion tab, while each block corresponds to a button. 30 | * The keys of the blocks object are the block names, as defined in the Sanity schema. 31 | */ 32 | blockPreviews: { 33 | title: string; 34 | blocks: { 35 | [name: string]: { 36 | description?: string; 37 | imageURL?: string; 38 | }; 39 | }; 40 | }[]; 41 | /** 42 | * Set this to true to show the "Other" accordion tab. 43 | * This tab contains all the blocks that are not in the blockPreviews prop. 44 | */ 45 | showOther?: boolean; 46 | /** 47 | * An array of block names to exclude from the block selector. 48 | */ 49 | excludedBlocks?: string[]; 50 | /** 51 | * Override the default text strings used in the block selector. 52 | */ 53 | text?: TextOptions; 54 | /** 55 | * An array of CSS selectors that correspond to the elements that should be replaced with the block selector button. 56 | * The default queries depend on the type prop, and should work for most cases. 57 | * But if they don't you have the freedom to override them. 58 | */ 59 | replaceQueries?: ReplaceQuery[]; 60 | }; 61 | 62 | export type ReplaceQuery = { 63 | level: 'document' | 'field'; 64 | query: string; 65 | }; 66 | 67 | export type TextOptions = { 68 | addItem?: string; 69 | dialogTitle?: string; 70 | noResults?: string; 71 | searchPlaceholder?: string; 72 | other?: string; 73 | }; 74 | 75 | export type OnBlockSelectFn = (block: Block) => void; 76 | -------------------------------------------------------------------------------- /src/utils.tsx: -------------------------------------------------------------------------------- 1 | import type {ClassValue} from 'clsx'; 2 | import clsx from 'clsx'; 3 | import type {FieldDefinition, SchemaTypeDefinition} from 'sanity'; 4 | import {twMerge} from 'tailwind-merge'; 5 | 6 | import type {Block, Group, InitialValue, Options} from './types.d'; 7 | 8 | export const cn = (...inputs: ClassValue[]) => { 9 | return twMerge(clsx(inputs)); 10 | }; 11 | 12 | export const filterBlockByString = (block: Block, filter: string) => { 13 | if (filter.length === 0) { 14 | return true; 15 | } 16 | const title = block.title.toLocaleLowerCase(); 17 | const description = block.description?.toLocaleLowerCase(); 18 | const filterLower = filter.toLocaleLowerCase(); 19 | return title.includes(filterLower) || (description && description.includes(filterLower)); 20 | }; 21 | 22 | export const isImageValid = async (url: URL) => { 23 | return new Promise((resolve) => { 24 | const img = new Image(); 25 | img.src = url.href; 26 | img.onload = () => resolve(true); 27 | img.onerror = () => { 28 | console.warn(`Image at ${url.href} failed to load`); 29 | resolve(false); 30 | }; 31 | }); 32 | }; 33 | 34 | // Used to find the most similar search result 35 | export const levenshteinDistance = (a: string, b: string) => { 36 | const distanceMatrix: number[][] = Array(b.length + 1) 37 | .fill(null) 38 | .map(() => Array(a.length + 1).fill(null)); 39 | 40 | for (let i = 0; i <= a.length; i += 1) { 41 | distanceMatrix[0][i] = i; 42 | } 43 | 44 | for (let j = 0; j <= b.length; j += 1) { 45 | distanceMatrix[j][0] = j; 46 | } 47 | 48 | for (let j = 1; j <= b.length; j += 1) { 49 | for (let i = 1; i <= a.length; i += 1) { 50 | const indicator = a[i - 1] === b[j - 1] ? 0 : 1; 51 | distanceMatrix[j][i] = Math.min( 52 | distanceMatrix[j][i - 1] + 1, 53 | distanceMatrix[j - 1][i] + 1, 54 | distanceMatrix[j - 1][i - 1] + indicator, 55 | ); 56 | } 57 | } 58 | 59 | return distanceMatrix[b.length][a.length]; 60 | }; 61 | 62 | export const schemaAndOptionsToGroups = ( 63 | schemaDefinitions: SchemaTypeDefinition[], 64 | options: Options, 65 | ): Group[] => { 66 | const {blockPreviews, excludedBlocks, showOther} = options; 67 | let blockCount = 0; 68 | const groups = blockPreviews.map((optionGroup) => { 69 | const groupBlocks = schemaAndOptionsGroupToBlocks(schemaDefinitions, optionGroup); 70 | blockCount += groupBlocks.length; 71 | return { 72 | title: optionGroup.title, 73 | blocks: groupBlocks, 74 | }; 75 | }); 76 | 77 | if (blockCount < schemaDefinitions.length - (excludedBlocks?.length ?? 0) && showOther) { 78 | groups.push({ 79 | title: options.text?.other ?? 'Other', 80 | blocks: schemaDefinitions 81 | .filter( 82 | (block) => 83 | !blockPreviews.some((group) => 84 | Object.keys(group.blocks).includes(block.name), 85 | ) && !excludedBlocks?.includes(block.name), 86 | ) 87 | .map((block): Block => { 88 | const initialValue = getSchemaInitialValues(block); 89 | return { 90 | _key: block.name, 91 | name: block.name, 92 | title: block.title ?? '', 93 | initialValue, 94 | }; 95 | }), 96 | }); 97 | } 98 | return groups; 99 | }; 100 | 101 | const schemaAndOptionsGroupToBlocks = ( 102 | schemaDefinitions: SchemaTypeDefinition[], 103 | group: Options['blockPreviews'][number], 104 | ): Block[] => { 105 | // Find schema definitions that are relevant for this group 106 | const definitions = schemaDefinitions.filter((definition) => 107 | Object.keys(group.blocks).includes(definition.name), 108 | ); 109 | 110 | // Map the definitions to blocks 111 | const groupBlocks = definitions.map((definition) => { 112 | const definitionOption = group.blocks[definition.name]; 113 | const initialValue = getSchemaInitialValues(definition); 114 | return { 115 | _key: definition.name, 116 | name: definition.name, 117 | title: definition.title ?? '', 118 | description: definitionOption?.description, 119 | imageURL: definitionOption?.imageURL 120 | ? new URL(definitionOption.imageURL, window.location.origin) 121 | : undefined, 122 | initialValue, 123 | }; 124 | }); 125 | 126 | return groupBlocks; 127 | }; 128 | 129 | const getSchemaInitialValues = ( 130 | schemaDefinition: SchemaTypeDefinition | FieldDefinition, 131 | ): InitialValue => { 132 | if ('fields' in schemaDefinition) { 133 | return ( 134 | schemaDefinition.fields?.reduce((acc, field) => { 135 | const nestedFields = 136 | typeof field.type === 'string' 137 | ? field.initialValue 138 | : // eslint-disable-next-line @typescript-eslint/no-explicit-any 139 | getSchemaInitialValues((field as any).type as SchemaTypeDefinition); 140 | 141 | return { 142 | ...acc, 143 | [field.name]: nestedFields, 144 | }; 145 | }, {} as InitialValue) ?? undefined 146 | ); 147 | } else if ('initialValue' in schemaDefinition) { 148 | return schemaDefinition.initialValue; 149 | } 150 | 151 | // Set an initial empty array value for array types 152 | if ( 153 | schemaDefinition.type === 'array' || 154 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 155 | (schemaDefinition as any).type?.jsonType === 'array' 156 | ) { 157 | return []; 158 | } 159 | 160 | return undefined; 161 | }; 162 | -------------------------------------------------------------------------------- /static/base.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/selvklart/sanity-block-selector/07065887f52531475e7c49ff5fb7235bd4ebb18a/static/base.png -------------------------------------------------------------------------------- /static/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/selvklart/sanity-block-selector/07065887f52531475e7c49ff5fb7235bd4ebb18a/static/search.png -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | import colors from 'tailwindcss/colors'; 3 | 4 | export default { 5 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}', './cosmos/**/*.{js,ts,jsx,tsx}'], 6 | theme: { 7 | extend: { 8 | colors: { 9 | 'card-border': `var(--card-border-color, ${colors.gray[300]})`, 10 | 'card-bg': `var(--card-bg-color, ${colors.white})`, 11 | 'card-fg': `var(--card-fg-color, ${colors.gray[900]})`, 12 | 'card-muted-fg': `var(--card-muted-fg-color, ${colors.gray[500]})`, 13 | }, 14 | }, 15 | }, 16 | plugins: [require('@tailwindcss/forms')], 17 | }; 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ESNext", "DOM", "DOM.Iterable"], 4 | "module": "esnext", 5 | "target": "esnext", 6 | "moduleResolution": "bundler", 7 | "moduleDetection": "force", 8 | "allowImportingTsExtensions": true, 9 | "noEmit": true, 10 | "composite": true, 11 | "strict": true, 12 | "downlevelIteration": true, 13 | "skipLibCheck": true, 14 | "jsx": "react-jsx", 15 | "allowSyntheticDefaultImports": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "allowJs": true, 18 | "types": [ 19 | "bun-types" // add Bun global 20 | ] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react'; 2 | import {defineConfig} from 'vite'; 3 | 4 | export default defineConfig({ 5 | root: 'cosmos', 6 | plugins: [react()], 7 | assetsInclude: ['**/static/**'], 8 | }); 9 | --------------------------------------------------------------------------------