From 41bf68a94c991e8ae8ad74e38a88b19a79873c09 Mon Sep 17 00:00:00 2001
From: Samuel Colvin <s@muelcolvin.com>
Date: Sat, 25 Nov 2023 19:59:04 +0000
Subject: [PATCH] Add `react-select` (#8)

---
 package-lock.json                             | 493 +++++++++++++++++-
 packages/fastui-bootstrap/src/index.tsx       |   3 +
 packages/fastui/package.json                  |   1 +
 packages/fastui/src/components/FormField.tsx  | 201 +++++--
 packages/fastui/src/components/ServerLoad.tsx |  13 +-
 packages/fastui/src/components/form.tsx       |   3 +-
 packages/fastui/src/components/index.tsx      |   7 +-
 packages/fastui/src/tools.ts                  |  41 +-
 python/demo/main.py                           |  35 +-
 python/fastui/components/__init__.py          |   2 -
 python/fastui/components/forms.py             |  24 +-
 python/fastui/forms.py                        |  34 +-
 python/fastui/json_schema.py                  | 107 ++--
 13 files changed, 852 insertions(+), 112 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index 5272734c..9c5434f1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -33,6 +33,186 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/@babel/code-frame": {
+      "version": "7.23.4",
+      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.4.tgz",
+      "integrity": "sha512-r1IONyb6Ia+jYR2vvIDhdWdlTGhqbBoFqLTQidzZ4kepUFH15ejXvFHxCVbtl7BOXIudsIubf4E81xeA3h3IXA==",
+      "dependencies": {
+        "@babel/highlight": "^7.23.4",
+        "chalk": "^2.4.2"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/code-frame/node_modules/ansi-styles": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+      "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+      "dependencies": {
+        "color-convert": "^1.9.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/@babel/code-frame/node_modules/chalk": {
+      "version": "2.4.2",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+      "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+      "dependencies": {
+        "ansi-styles": "^3.2.1",
+        "escape-string-regexp": "^1.0.5",
+        "supports-color": "^5.3.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/@babel/code-frame/node_modules/color-convert": {
+      "version": "1.9.3",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+      "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+      "dependencies": {
+        "color-name": "1.1.3"
+      }
+    },
+    "node_modules/@babel/code-frame/node_modules/color-name": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+      "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="
+    },
+    "node_modules/@babel/code-frame/node_modules/escape-string-regexp": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+      "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
+      "engines": {
+        "node": ">=0.8.0"
+      }
+    },
+    "node_modules/@babel/code-frame/node_modules/has-flag": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+      "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/@babel/code-frame/node_modules/supports-color": {
+      "version": "5.5.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+      "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+      "dependencies": {
+        "has-flag": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/@babel/helper-module-imports": {
+      "version": "7.22.15",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz",
+      "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==",
+      "dependencies": {
+        "@babel/types": "^7.22.15"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-string-parser": {
+      "version": "7.23.4",
+      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz",
+      "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-identifier": {
+      "version": "7.22.20",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz",
+      "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/highlight": {
+      "version": "7.23.4",
+      "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz",
+      "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==",
+      "dependencies": {
+        "@babel/helper-validator-identifier": "^7.22.20",
+        "chalk": "^2.4.2",
+        "js-tokens": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/highlight/node_modules/ansi-styles": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+      "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+      "dependencies": {
+        "color-convert": "^1.9.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/@babel/highlight/node_modules/chalk": {
+      "version": "2.4.2",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+      "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+      "dependencies": {
+        "ansi-styles": "^3.2.1",
+        "escape-string-regexp": "^1.0.5",
+        "supports-color": "^5.3.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/@babel/highlight/node_modules/color-convert": {
+      "version": "1.9.3",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+      "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+      "dependencies": {
+        "color-name": "1.1.3"
+      }
+    },
+    "node_modules/@babel/highlight/node_modules/color-name": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+      "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="
+    },
+    "node_modules/@babel/highlight/node_modules/escape-string-regexp": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+      "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
+      "engines": {
+        "node": ">=0.8.0"
+      }
+    },
+    "node_modules/@babel/highlight/node_modules/has-flag": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+      "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/@babel/highlight/node_modules/supports-color": {
+      "version": "5.5.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+      "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+      "dependencies": {
+        "has-flag": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
     "node_modules/@babel/runtime": {
       "version": "7.23.4",
       "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.4.tgz",
@@ -44,6 +224,122 @@
         "node": ">=6.9.0"
       }
     },
+    "node_modules/@babel/types": {
+      "version": "7.23.4",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.4.tgz",
+      "integrity": "sha512-7uIFwVYpoplT5jp/kVv6EF93VaJ8H+Yn5IczYiaAi98ajzjfoZfslet/e0sLh+wVBjb2qqIut1b0S26VSafsSQ==",
+      "dependencies": {
+        "@babel/helper-string-parser": "^7.23.4",
+        "@babel/helper-validator-identifier": "^7.22.20",
+        "to-fast-properties": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@emotion/babel-plugin": {
+      "version": "11.11.0",
+      "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz",
+      "integrity": "sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ==",
+      "dependencies": {
+        "@babel/helper-module-imports": "^7.16.7",
+        "@babel/runtime": "^7.18.3",
+        "@emotion/hash": "^0.9.1",
+        "@emotion/memoize": "^0.8.1",
+        "@emotion/serialize": "^1.1.2",
+        "babel-plugin-macros": "^3.1.0",
+        "convert-source-map": "^1.5.0",
+        "escape-string-regexp": "^4.0.0",
+        "find-root": "^1.1.0",
+        "source-map": "^0.5.7",
+        "stylis": "4.2.0"
+      }
+    },
+    "node_modules/@emotion/cache": {
+      "version": "11.11.0",
+      "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.11.0.tgz",
+      "integrity": "sha512-P34z9ssTCBi3e9EI1ZsWpNHcfY1r09ZO0rZbRO2ob3ZQMnFI35jB536qoXbkdesr5EUhYi22anuEJuyxifaqAQ==",
+      "dependencies": {
+        "@emotion/memoize": "^0.8.1",
+        "@emotion/sheet": "^1.2.2",
+        "@emotion/utils": "^1.2.1",
+        "@emotion/weak-memoize": "^0.3.1",
+        "stylis": "4.2.0"
+      }
+    },
+    "node_modules/@emotion/hash": {
+      "version": "0.9.1",
+      "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.1.tgz",
+      "integrity": "sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ=="
+    },
+    "node_modules/@emotion/memoize": {
+      "version": "0.8.1",
+      "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz",
+      "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA=="
+    },
+    "node_modules/@emotion/react": {
+      "version": "11.11.1",
+      "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.1.tgz",
+      "integrity": "sha512-5mlW1DquU5HaxjLkfkGN1GA/fvVGdyHURRiX/0FHl2cfIfRxSOfmxEH5YS43edp0OldZrZ+dkBKbngxcNCdZvA==",
+      "dependencies": {
+        "@babel/runtime": "^7.18.3",
+        "@emotion/babel-plugin": "^11.11.0",
+        "@emotion/cache": "^11.11.0",
+        "@emotion/serialize": "^1.1.2",
+        "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1",
+        "@emotion/utils": "^1.2.1",
+        "@emotion/weak-memoize": "^0.3.1",
+        "hoist-non-react-statics": "^3.3.1"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@emotion/serialize": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.1.2.tgz",
+      "integrity": "sha512-zR6a/fkFP4EAcCMQtLOhIgpprZOwNmCldtpaISpvz348+DP4Mz8ZoKaGGCQpbzepNIUWbq4w6hNZkwDyKoS+HA==",
+      "dependencies": {
+        "@emotion/hash": "^0.9.1",
+        "@emotion/memoize": "^0.8.1",
+        "@emotion/unitless": "^0.8.1",
+        "@emotion/utils": "^1.2.1",
+        "csstype": "^3.0.2"
+      }
+    },
+    "node_modules/@emotion/sheet": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.2.2.tgz",
+      "integrity": "sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA=="
+    },
+    "node_modules/@emotion/unitless": {
+      "version": "0.8.1",
+      "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz",
+      "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ=="
+    },
+    "node_modules/@emotion/use-insertion-effect-with-fallbacks": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz",
+      "integrity": "sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==",
+      "peerDependencies": {
+        "react": ">=16.8.0"
+      }
+    },
+    "node_modules/@emotion/utils": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.2.1.tgz",
+      "integrity": "sha512-Y2tGf3I+XVnajdItskUCn6LX+VUDmP6lTL4fcqsXAv43dnlbZiuW4MWQW38rW/BVWSE7Q/7+XQocmpnRYILUmg=="
+    },
+    "node_modules/@emotion/weak-memoize": {
+      "version": "0.3.1",
+      "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz",
+      "integrity": "sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww=="
+    },
     "node_modules/@esbuild/android-arm": {
       "version": "0.19.5",
       "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.5.tgz",
@@ -452,6 +748,28 @@
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
       }
     },
+    "node_modules/@floating-ui/core": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.5.0.tgz",
+      "integrity": "sha512-kK1h4m36DQ0UHGj5Ah4db7R0rHemTqqO0QLvUqi1/mUUp3LuAWbWxdxSIf/XsnH9VS6rRVPLJCncjRzUvyCLXg==",
+      "dependencies": {
+        "@floating-ui/utils": "^0.1.3"
+      }
+    },
+    "node_modules/@floating-ui/dom": {
+      "version": "1.5.3",
+      "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.5.3.tgz",
+      "integrity": "sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA==",
+      "dependencies": {
+        "@floating-ui/core": "^1.4.2",
+        "@floating-ui/utils": "^0.1.3"
+      }
+    },
+    "node_modules/@floating-ui/utils": {
+      "version": "0.1.6",
+      "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz",
+      "integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A=="
+    },
     "node_modules/@humanwhocodes/config-array": {
       "version": "0.11.13",
       "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz",
@@ -994,6 +1312,11 @@
         "undici-types": "~5.26.4"
       }
     },
+    "node_modules/@types/parse-json": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
+      "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="
+    },
     "node_modules/@types/prop-types": {
       "version": "15.7.10",
       "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.10.tgz",
@@ -1492,6 +1815,20 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/babel-plugin-macros": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
+      "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==",
+      "dependencies": {
+        "@babel/runtime": "^7.12.5",
+        "cosmiconfig": "^7.0.0",
+        "resolve": "^1.19.0"
+      },
+      "engines": {
+        "node": ">=10",
+        "npm": ">=6"
+      }
+    },
     "node_modules/bail": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
@@ -1595,7 +1932,6 @@
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
       "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
-      "dev": true,
       "engines": {
         "node": ">=6"
       }
@@ -1727,6 +2063,26 @@
       "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
       "dev": true
     },
+    "node_modules/convert-source-map": {
+      "version": "1.9.0",
+      "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
+      "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="
+    },
+    "node_modules/cosmiconfig": {
+      "version": "7.1.0",
+      "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
+      "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==",
+      "dependencies": {
+        "@types/parse-json": "^4.0.0",
+        "import-fresh": "^3.2.1",
+        "parse-json": "^5.0.0",
+        "path-type": "^4.0.0",
+        "yaml": "^1.10.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
     "node_modules/cross-spawn": {
       "version": "7.0.3",
       "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@@ -1864,6 +2220,14 @@
         "csstype": "^3.0.2"
       }
     },
+    "node_modules/error-ex": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
+      "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
+      "dependencies": {
+        "is-arrayish": "^0.2.1"
+      }
+    },
     "node_modules/es-abstract": {
       "version": "1.22.3",
       "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.3.tgz",
@@ -2020,7 +2384,6 @@
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
       "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
-      "dev": true,
       "engines": {
         "node": ">=10"
       },
@@ -2588,6 +2951,11 @@
         "node": ">=8"
       }
     },
+    "node_modules/find-root": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
+      "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="
+    },
     "node_modules/find-up": {
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -2664,7 +3032,6 @@
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
       "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
-      "dev": true,
       "funding": {
         "url": "https://github.com/sponsors/ljharb"
       }
@@ -2913,7 +3280,6 @@
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz",
       "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==",
-      "dev": true,
       "dependencies": {
         "function-bind": "^1.1.2"
       },
@@ -3029,6 +3395,14 @@
         "node": "*"
       }
     },
+    "node_modules/hoist-non-react-statics": {
+      "version": "3.3.2",
+      "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
+      "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
+      "dependencies": {
+        "react-is": "^16.7.0"
+      }
+    },
     "node_modules/html-url-attributes": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.0.tgz",
@@ -3056,7 +3430,6 @@
       "version": "3.3.0",
       "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
       "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
-      "dev": true,
       "dependencies": {
         "parent-module": "^1.0.0",
         "resolve-from": "^4.0.0"
@@ -3156,6 +3529,11 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/is-arrayish": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
+      "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="
+    },
     "node_modules/is-async-function": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz",
@@ -3242,7 +3620,6 @@
       "version": "2.13.1",
       "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz",
       "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==",
-      "dev": true,
       "dependencies": {
         "hasown": "^2.0.0"
       },
@@ -3557,6 +3934,11 @@
       "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
       "dev": true
     },
+    "node_modules/json-parse-even-better-errors": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
+      "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="
+    },
     "node_modules/json-schema-traverse": {
       "version": "0.4.1",
       "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -3619,6 +4001,11 @@
         "node": ">= 0.8.0"
       }
     },
+    "node_modules/lines-and-columns": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+      "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="
+    },
     "node_modules/locate-path": {
       "version": "6.0.0",
       "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -3901,6 +4288,11 @@
         "url": "https://opencollective.com/unified"
       }
     },
+    "node_modules/memoize-one": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
+      "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="
+    },
     "node_modules/merge2": {
       "version": "1.4.1",
       "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -4694,7 +5086,6 @@
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
       "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
-      "dev": true,
       "dependencies": {
         "callsites": "^3.0.0"
       },
@@ -4728,6 +5119,23 @@
         "url": "https://github.com/sponsors/wooorm"
       }
     },
+    "node_modules/parse-json": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
+      "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
+      "dependencies": {
+        "@babel/code-frame": "^7.0.0",
+        "error-ex": "^1.3.1",
+        "json-parse-even-better-errors": "^2.3.0",
+        "lines-and-columns": "^1.1.6"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
     "node_modules/path-exists": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -4758,14 +5166,12 @@
     "node_modules/path-parse": {
       "version": "1.0.7",
       "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
-      "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
-      "dev": true
+      "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
     },
     "node_modules/path-type": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
       "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
-      "dev": true,
       "engines": {
         "node": ">=8"
       }
@@ -4992,6 +5398,26 @@
         "react": ">=18"
       }
     },
+    "node_modules/react-select": {
+      "version": "5.8.0",
+      "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.8.0.tgz",
+      "integrity": "sha512-TfjLDo58XrhP6VG5M/Mi56Us0Yt8X7xD6cDybC7yoRMUNm7BGO7qk8J0TLQOua/prb8vUOtsfnXZwfm30HGsAA==",
+      "dependencies": {
+        "@babel/runtime": "^7.12.0",
+        "@emotion/cache": "^11.4.0",
+        "@emotion/react": "^11.8.1",
+        "@floating-ui/dom": "^1.0.1",
+        "@types/react-transition-group": "^4.4.0",
+        "memoize-one": "^6.0.0",
+        "prop-types": "^15.6.0",
+        "react-transition-group": "^4.3.0",
+        "use-isomorphic-layout-effect": "^1.1.2"
+      },
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+        "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+      }
+    },
     "node_modules/react-syntax-highlighter": {
       "version": "15.5.0",
       "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.5.0.tgz",
@@ -5163,8 +5589,6 @@
       "version": "1.22.8",
       "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
       "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==",
-      "dev": true,
-      "peer": true,
       "dependencies": {
         "is-core-module": "^2.13.0",
         "path-parse": "^1.0.7",
@@ -5181,7 +5605,6 @@
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
       "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
-      "dev": true,
       "engines": {
         "node": ">=4"
       }
@@ -5416,6 +5839,14 @@
         "node": ">=8"
       }
     },
+    "node_modules/source-map": {
+      "version": "0.5.7",
+      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+      "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/source-map-js": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
@@ -5540,6 +5971,11 @@
         "inline-style-parser": "0.1.1"
       }
     },
+    "node_modules/stylis": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
+      "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="
+    },
     "node_modules/supports-color": {
       "version": "7.2.0",
       "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -5556,7 +5992,6 @@
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
       "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
-      "dev": true,
       "engines": {
         "node": ">= 0.4"
       },
@@ -5570,6 +6005,14 @@
       "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
       "dev": true
     },
+    "node_modules/to-fast-properties": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
+      "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
+      "engines": {
+        "node": ">=4"
+      }
+    },
     "node_modules/to-regex-range": {
       "version": "5.0.1",
       "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -5856,6 +6299,19 @@
         "punycode": "^2.1.0"
       }
     },
+    "node_modules/use-isomorphic-layout-effect": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz",
+      "integrity": "sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/vanilla": {
       "resolved": "packages/vanilla",
       "link": true
@@ -6061,6 +6517,14 @@
       "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
       "dev": true
     },
+    "node_modules/yaml": {
+      "version": "1.10.2",
+      "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
+      "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
+      "engines": {
+        "node": ">= 6"
+      }
+    },
     "node_modules/yocto-queue": {
       "version": "0.1.0",
       "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
@@ -6088,6 +6552,7 @@
         "react": "^18.2.0",
         "react-dom": "^18.2.0",
         "react-markdown": "^9.0.1",
+        "react-select": "^5.8.0",
         "react-syntax-highlighter": "^15.5.0",
         "remark-gfm": "^4.0.0"
       },
diff --git a/packages/fastui-bootstrap/src/index.tsx b/packages/fastui-bootstrap/src/index.tsx
index 8584454a..e57a93bf 100644
--- a/packages/fastui-bootstrap/src/index.tsx
+++ b/packages/fastui-bootstrap/src/index.tsx
@@ -39,6 +39,7 @@ export const classNameGenerator: ClassNameGenerator = ({ props, fullPath, subEle
     case 'FormFieldInput':
     case 'FormFieldCheckbox':
     case 'FormFieldSelect':
+    case 'FormFieldSelectSearch':
     case 'FormFieldFile':
       return formFieldClassName(props, subElement)
     case 'Navbar':
@@ -54,6 +55,8 @@ function formFieldClassName(props: components.FormFieldProps, subElement?: strin
       return props.error ? 'is-invalid form-control' : 'form-control'
     case 'select':
       return 'form-select'
+    case 'select-react':
+      return ''
     case 'label':
       return { 'form-label': true, 'fw-bold': props.required }
     case 'error':
diff --git a/packages/fastui/package.json b/packages/fastui/package.json
index 9cf5f34f..1bb82218 100644
--- a/packages/fastui/package.json
+++ b/packages/fastui/package.json
@@ -9,6 +9,7 @@
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
     "react-markdown": "^9.0.1",
+    "react-select": "^5.8.0",
     "react-syntax-highlighter": "^15.5.0",
     "remark-gfm": "^4.0.0"
   },
diff --git a/packages/fastui/src/components/FormField.tsx b/packages/fastui/src/components/FormField.tsx
index 94c9eeb4..71270958 100644
--- a/packages/fastui/src/components/FormField.tsx
+++ b/packages/fastui/src/components/FormField.tsx
@@ -1,6 +1,9 @@
 import { FC, useState } from 'react'
+import AsyncSelect from 'react-select/async'
+import Select, { StylesConfig } from 'react-select'
 
 import { ClassName, useClassName } from '../hooks/className'
+import { debounce, useRequest } from '../tools'
 
 interface BaseFormFieldProps {
   name: string
@@ -12,7 +15,12 @@ interface BaseFormFieldProps {
   className?: ClassName
 }
 
-export type FormFieldProps = FormFieldInputProps | FormFieldCheckboxProps | FormFieldSelectProps | FormFieldFileProps
+export type FormFieldProps =
+  | FormFieldInputProps
+  | FormFieldCheckboxProps
+  | FormFieldFileProps
+  | FormFieldSelectProps
+  | FormFieldSelectSearchProps
 
 interface FormFieldInputProps extends BaseFormFieldProps {
   type: 'FormFieldInput'
@@ -23,7 +31,6 @@ interface FormFieldInputProps extends BaseFormFieldProps {
 
 export const FormFieldInputComp: FC<FormFieldInputProps> = (props) => {
   const { name, placeholder, required, htmlType, locked } = props
-  const [value, setValue] = useState(props.initial ?? '')
 
   return (
     <div className={useClassName(props)}>
@@ -31,8 +38,7 @@ export const FormFieldInputComp: FC<FormFieldInputProps> = (props) => {
       <input
         type={htmlType}
         className={useClassName(props, { el: 'input' })}
-        value={value}
-        onChange={(e) => setValue(e.target.value)}
+        defaultValue={props.initial}
         id={inputId(props)}
         name={name}
         required={required}
@@ -71,62 +77,185 @@ export const FormFieldCheckboxComp: FC<FormFieldCheckboxProps> = (props) => {
   )
 }
 
-interface FormFieldSelectProps extends BaseFormFieldProps {
-  type: 'FormFieldSelect'
-  choices: [string, string][]
-  initial?: string
+interface FormFieldFileProps extends BaseFormFieldProps {
+  type: 'FormFieldFile'
+  multiple?: boolean
+  accept?: string
 }
 
-export const FormFieldSelectComp: FC<FormFieldSelectProps> = (props) => {
-  const { name, required, locked, choices } = props
-  const [value, setValue] = useState(props.initial ?? '')
+export const FormFieldFileComp: FC<FormFieldFileProps> = (props) => {
+  const { name, required, locked, multiple, accept } = props
 
   return (
     <div className={useClassName(props)}>
       <Label {...props} />
-      <select
+      <input
+        type="file"
+        className={useClassName(props, { el: 'input' })}
         id={inputId(props)}
-        className={useClassName(props, { el: 'select' })}
-        value={value}
-        onChange={(e) => setValue(e.target.value)}
         name={name}
         required={required}
         disabled={locked}
-        aria-describedby={descId(props)}
-      >
-        <option></option>
-        {choices.map(([value, label]) => (
-          <option key={value} value={value}>
-            {label}
-          </option>
-        ))}
-      </select>
+        multiple={multiple ?? false}
+        accept={accept}
+      />
       <ErrorDescription {...props} />
     </div>
   )
 }
 
-interface FormFieldFileProps extends BaseFormFieldProps {
-  type: 'FormFieldFile'
-  multiple: boolean
-  accept?: string
+interface SelectOption {
+  value: string
+  label: string
 }
 
-export const FormFieldFileComp: FC<FormFieldFileProps> = (props) => {
-  const { name, required, locked, multiple, accept } = props
+interface SelectGroup {
+  label: string
+  options: SelectOption[]
+}
+
+type SelectOptions = SelectOption[] | SelectGroup[]
+
+// cheat slightly and match bootstrap 😱
+// TODO make this configurable as an argument to `FastUI`
+const styles: StylesConfig = {
+  control: (base) => ({ ...base, borderRadius: '0.375rem', border: '1px solid #dee2e6' }),
+}
+
+interface FormFieldSelectProps extends BaseFormFieldProps {
+  type: 'FormFieldSelect'
+  options: SelectOptions
+  initial?: string
+  multiple?: boolean
+  vanilla?: boolean
+}
+
+export const FormFieldSelectComp: FC<FormFieldSelectProps> = (props) => {
+  const { name, required, locked, options, multiple, initial, vanilla } = props
+
+  const className = useClassName(props)
+  const classNameSelect = useClassName(props, { el: 'select' })
+  const classNameSelectReact = useClassName(props, { el: 'select-react' })
+  if (vanilla) {
+    return (
+      <div className={className}>
+        <Label {...props} />
+        <select
+          id={inputId(props)}
+          className={classNameSelect}
+          defaultValue={initial}
+          multiple={multiple}
+          name={name}
+          required={required}
+          disabled={locked}
+          aria-describedby={descId(props)}
+        >
+          {multiple ? null : <option></option>}
+          {options.map((option, i) => (
+            <SelectOptionComp key={i} option={option} />
+          ))}
+        </select>
+        <ErrorDescription {...props} />
+      </div>
+    )
+  } else {
+    return (
+      <div className={className}>
+        <Label {...props} />
+        <Select
+          id={inputId(props)}
+          className={classNameSelectReact}
+          isMulti={multiple ?? false}
+          isClearable
+          defaultValue={findDefault(options, initial)}
+          name={name}
+          required={required}
+          isDisabled={locked}
+          options={options}
+          aria-describedby={descId(props)}
+          styles={styles}
+        />
+        <ErrorDescription {...props} />
+      </div>
+    )
+  }
+}
+
+const SelectOptionComp: FC<{ option: SelectOption | SelectGroup }> = ({ option }) => {
+  if ('options' in option) {
+    return (
+      <optgroup label={option.label}>
+        {option.options.map((o) => (
+          <SelectOptionComp key={o.value} option={o} />
+        ))}
+      </optgroup>
+    )
+  } else {
+    return <option value={option.value}>{option.label}</option>
+  }
+}
+
+function findDefault(options: SelectOptions, value?: string): SelectOption | undefined {
+  for (const option of options) {
+    if ('options' in option) {
+      const found = findDefault(option.options, value)
+      if (found) {
+        return found
+      }
+    } else if (option.value === value) {
+      return option
+    }
+  }
+}
+
+interface FormFieldSelectSearchProps extends BaseFormFieldProps {
+  type: 'FormFieldSelectSearch'
+  searchUrl: string
+  debounce?: number
+  initial?: SelectOption
+  multiple?: boolean
+}
+
+export const FormFieldSelectSearchComp: FC<FormFieldSelectSearchProps> = (props) => {
+  const { name, required, locked, searchUrl, initial, multiple } = props
+  const [isLoading, setIsLoading] = useState(false)
+  const request = useRequest()
+
+  const loadOptions = debounce((inputValue: string, callback: (options: SelectOptions) => void) => {
+    setIsLoading(true)
+    request({
+      url: searchUrl,
+      query: { q: inputValue },
+    })
+      .then(([, response]) => {
+        const { options } = response as { options: SelectOptions }
+        callback(options)
+        setIsLoading(false)
+      })
+      .catch(() => {
+        setIsLoading(false)
+      })
+  }, props.debounce ?? 300)
 
   return (
     <div className={useClassName(props)}>
       <Label {...props} />
-      <input
-        type="file"
-        className={useClassName(props, { el: 'input' })}
+      <AsyncSelect
         id={inputId(props)}
+        className={useClassName(props, { el: 'select-react' })}
+        isMulti={multiple ?? false}
+        cacheOptions
+        isClearable
+        defaultOptions
+        loadOptions={loadOptions}
+        defaultValue={initial}
+        noOptionsMessage={({ inputValue }) => (inputValue ? 'No results' : 'Type to search')}
         name={name}
         required={required}
-        disabled={locked}
-        multiple={multiple}
-        accept={accept}
+        isDisabled={locked}
+        isLoading={isLoading}
+        aria-describedby={descId(props)}
+        styles={styles}
       />
       <ErrorDescription {...props} />
     </div>
diff --git a/packages/fastui/src/components/ServerLoad.tsx b/packages/fastui/src/components/ServerLoad.tsx
index e4b2e495..5564bb36 100644
--- a/packages/fastui/src/components/ServerLoad.tsx
+++ b/packages/fastui/src/components/ServerLoad.tsx
@@ -2,7 +2,7 @@ import { FC, useContext, useEffect, useState } from 'react'
 
 import { ErrorContext } from '../hooks/error'
 import { ReloadContext } from '../hooks/dev'
-import { request } from '../tools'
+import { useRequest } from '../tools'
 import { DefaultLoading } from '../DefaultLoading'
 import { ConfigContext } from '../hooks/config'
 
@@ -19,9 +19,9 @@ export const ServerLoadComp: FC<ServerLoadProps> = ({ url }) => {
   const { error, setError } = useContext(ErrorContext)
   const reloadValue = useContext(ReloadContext)
   const { rootUrl, pathSendMode, Loading } = useContext(ConfigContext)
+  const request = useRequest()
 
   useEffect(() => {
-    // setViewData(null)
     let fetchUrl = rootUrl
     if (pathSendMode === 'query') {
       fetchUrl += `?path=${encodeURIComponent(url)}`
@@ -31,15 +31,12 @@ export const ServerLoadComp: FC<ServerLoadProps> = ({ url }) => {
 
     const promise = request({ url: fetchUrl })
 
-    promise
-      .then(([, data]) => setComponentProps(data as FastProps[]))
-      .catch((e) => {
-        setError({ title: 'Request Error', description: e.message })
-      })
+    promise.then(([, data]) => setComponentProps(data as FastProps[]))
+
     return () => {
       promise.then(() => null)
     }
-  }, [rootUrl, pathSendMode, url, setError, reloadValue])
+  }, [rootUrl, pathSendMode, url, setError, reloadValue, request])
 
   if (componentProps === null) {
     if (error) {
diff --git a/packages/fastui/src/components/form.tsx b/packages/fastui/src/components/form.tsx
index b92818b2..25ae6f48 100644
--- a/packages/fastui/src/components/form.tsx
+++ b/packages/fastui/src/components/form.tsx
@@ -2,7 +2,7 @@ import { FC, FormEvent, useState } from 'react'
 
 import { ClassName, useClassName } from '../hooks/className'
 import { useFireEvent, AnyEvent } from '../hooks/events'
-import { request } from '../tools'
+import { useRequest } from '../tools'
 
 import { FastProps, AnyCompList } from './index'
 
@@ -37,6 +37,7 @@ export const FormComp: FC<FormProps | ModelFormProps> = (props) => {
   const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})
   const [error, setError] = useState<string | null>(null)
   const { fireEvent } = useFireEvent()
+  const request = useRequest()
 
   const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
     e.preventDefault()
diff --git a/packages/fastui/src/components/index.tsx b/packages/fastui/src/components/index.tsx
index 3f7b61b9..ea9f31bc 100644
--- a/packages/fastui/src/components/index.tsx
+++ b/packages/fastui/src/components/index.tsx
@@ -17,6 +17,7 @@ import {
   FormFieldInputComp,
   FormFieldCheckboxComp,
   FormFieldSelectComp,
+  FormFieldSelectSearchComp,
   FormFieldFileComp,
 } from './FormField'
 import { ButtonComp, ButtonProps } from './button'
@@ -136,10 +137,12 @@ export const AnyComp: FC<FastProps> = (props) => {
         return <FormFieldInputComp {...props} />
       case 'FormFieldCheckbox':
         return <FormFieldCheckboxComp {...props} />
-      case 'FormFieldSelect':
-        return <FormFieldSelectComp {...props} />
       case 'FormFieldFile':
         return <FormFieldFileComp {...props} />
+      case 'FormFieldSelect':
+        return <FormFieldSelectComp {...props} />
+      case 'FormFieldSelectSearch':
+        return <FormFieldSelectSearchComp {...props} />
       case 'Modal':
         return <ModalComp {...props} />
       case 'Table':
diff --git a/packages/fastui/src/tools.ts b/packages/fastui/src/tools.ts
index 3e4a2da9..201952d7 100644
--- a/packages/fastui/src/tools.ts
+++ b/packages/fastui/src/tools.ts
@@ -1,8 +1,29 @@
+import { useCallback, useContext } from 'react'
+
+import { ErrorContext } from './hooks/error'
+
+export function useRequest(): (args: Request) => Promise<[number, any]> {
+  const { setError } = useContext(ErrorContext)
+
+  return useCallback(
+    async (args: Request) => {
+      try {
+        return await request(args)
+      } catch (e) {
+        setError({ title: 'Request Error', description: (e as any)?.message })
+        throw e
+      }
+    },
+    [setError],
+  )
+}
+
 interface Request {
   url: string
   method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
   // defaults to 200
   expectedStatus?: number[]
+  query?: Record<string, string>
   json?: Record<string, any>
   formData?: FormData
   headers?: Record<string, string>
@@ -18,10 +39,11 @@ class RequestError extends Error {
   }
 }
 
-export async function request({
+async function request({
   url,
   method,
   headers,
+  query,
   json,
   expectedStatus,
   formData,
@@ -39,6 +61,11 @@ export async function request({
     method = method ?? 'POST'
   }
 
+  if (query) {
+    const searchParams = new URLSearchParams(query)
+    url = `${url}?${searchParams.toString()}`
+  }
+
   headers = headers ?? {}
   if (contentType && !headers['Content-Type']) {
     headers['Content-Type'] = contentType
@@ -99,3 +126,15 @@ function responseOk(response: Response, expectedStatus?: number[]) {
 export function unreachable(msg: string, unexpectedValue: never, args?: any) {
   console.warn(msg, { unexpectedValue }, args)
 }
+
+type Callable = (...args: any[]) => void
+
+export function debounce<C extends Callable>(fn: C, delay: number): C {
+  let timerId: any
+
+  // @ts-expect-error - functions are contravariant, so this should be fine, no idea how to satisfy TS though
+  return (...args: any[]) => {
+    clearTimeout(timerId)
+    timerId = setTimeout(() => fn(...args), delay)
+  }
+}
diff --git a/python/demo/main.py b/python/demo/main.py
index 90d6e103..0b3a29d3 100644
--- a/python/demo/main.py
+++ b/python/demo/main.py
@@ -1,6 +1,7 @@
 from __future__ import annotations as _annotations
 
 import asyncio
+from collections import defaultdict
 from datetime import date
 from enum import StrEnum
 from typing import Annotated, Literal
@@ -10,7 +11,8 @@
 from fastui import components as c
 from fastui.display import Display
 from fastui.events import BackEvent, GoToEvent, PageEvent
-from fastui.forms import FormFile, FormResponse, fastui_form
+from fastui.forms import FormFile, FormResponse, SelectSearchResponse, fastui_form
+from httpx import AsyncClient
 from pydantic import BaseModel, Field, SecretStr, field_validator
 from pydantic_core import PydanticCustomError
 
@@ -94,7 +96,6 @@ def read_root() -> list[AnyComponent]:
                     ],
                     open_trigger=PageEvent(name='dynamic-modal'),
                 ),
-                c.Code(text='print("Hello World")', language='python'),
             ],
         ),
     ]
@@ -153,8 +154,9 @@ class ToolEnum(StrEnum):
 
 class MyFormModel(BaseModel):
     name: str = Field(default='foobar', title='Name', min_length=3, description='Your name')
-    # tool: ToolEnum = Field(json_schema_extra={'enum_display_values': {'hammer': 'Big Hammer'}})
-    task: Literal['build', 'destroy'] | None = None
+    # tool: ToolEnum = Field(json_schema_extra={'enum_labels': {'hammer': 'Big Hammer'}})
+    task: Literal['build', 'destroy'] | None = 'build'
+    tasks: set[Literal['build', 'destroy']]
     profile_pic: Annotated[UploadFile, FormFile(accept='image/*', max_size=16_000)]
     # profile_pics: Annotated[list[UploadFile], FormFile(accept='image/*', max_size=400)]
     # binary: bytes
@@ -165,6 +167,8 @@ class MyFormModel(BaseModel):
     # enabled: bool = False
     # nested: NestedFormModel
     password: SecretStr
+    search: str = Field(json_schema_extra={'search_url': '/api/search'})
+    searches: list[str] = Field(json_schema_extra={'search_url': '/api/search'})
 
     @field_validator('name')
     def name_validator(cls, v: str) -> str:
@@ -173,6 +177,29 @@ def name_validator(cls, v: str) -> str:
         return v
 
 
+@app.get('/api/search', response_model=SelectSearchResponse)
+async def search_view(q: str) -> SelectSearchResponse:
+    async with AsyncClient() as client:
+        path_ends = f'name/{q}' if q else 'all'
+        r = await client.get(f'https://restcountries.com/v3.1/{path_ends}')
+        if r.status_code == 404:
+            options = []
+        else:
+            r.raise_for_status()
+            data = r.json()
+            if path_ends == 'all':
+                # if we got all, filter to the 20 most populous countries
+                data.sort(key=lambda x: x['population'], reverse=True)
+                data = data[0:20]
+                data.sort(key=lambda x: x['name']['common'])
+
+            regions = defaultdict(list)
+            for co in data:
+                regions[co['region']].append({'value': co['cca3'], 'label': co['name']['common']})
+            options = [{'label': k, 'options': v} for k, v in regions.items()]
+    return SelectSearchResponse(options=options)
+
+
 @app.get('/api/form', response_model=FastUI, response_model_exclude_none=True)
 def form_view() -> list[AnyComponent]:
     return [
diff --git a/python/fastui/components/__init__.py b/python/fastui/components/__init__.py
index 859d4eb5..ec530c02 100644
--- a/python/fastui/components/__init__.py
+++ b/python/fastui/components/__init__.py
@@ -25,8 +25,6 @@
     'Div',
     'Page',
     'Heading',
-    'Row',
-    'Col',
     'Button',
     'Modal',
     'ModelForm',
diff --git a/python/fastui/components/forms.py b/python/fastui/components/forms.py
index 45a85005..df2affd9 100644
--- a/python/fastui/components/forms.py
+++ b/python/fastui/components/forms.py
@@ -5,6 +5,7 @@
 
 import pydantic
 
+from .. import forms
 from . import extra
 
 if typing.TYPE_CHECKING:
@@ -35,19 +36,30 @@ class FormFieldCheckbox(BaseFormField):
     type: typing.Literal['FormFieldCheckbox'] = 'FormFieldCheckbox'
 
 
+class FormFieldFile(BaseFormField):
+    multiple: bool | None = None
+    accept: str | None = None
+    type: typing.Literal['FormFieldFile'] = 'FormFieldFile'
+
+
 class FormFieldSelect(BaseFormField):
-    choices: list[tuple[str, str]]
+    options: list[forms.SelectOption] | list[forms.SelectGroup]
+    multiple: bool | None = None
     initial: str | None = None
+    vanilla: bool | None = None
     type: typing.Literal['FormFieldSelect'] = 'FormFieldSelect'
 
 
-class FormFieldFile(BaseFormField):
-    multiple: bool = False
-    accept: str | None = None
-    type: typing.Literal['FormFieldFile'] = 'FormFieldFile'
+class FormFieldSelectSearch(BaseFormField):
+    search_url: str = pydantic.Field(serialization_alias='searchUrl')
+    multiple: bool | None = None
+    initial: forms.SelectOption | None = None
+    # time in ms to debounce requests by, defaults to 300ms
+    debounce: int | None = None
+    type: typing.Literal['FormFieldSelectSearch'] = 'FormFieldSelectSearch'
 
 
-FormField = FormFieldInput | FormFieldCheckbox | FormFieldSelect | FormFieldFile
+FormField = FormFieldInput | FormFieldCheckbox | FormFieldFile | FormFieldSelect | FormFieldSelectSearch
 
 
 class BaseForm(pydantic.BaseModel, ABC, defer_build=True):
diff --git a/python/fastui/forms.py b/python/fastui/forms.py
index cc292dac..f81a612a 100644
--- a/python/fastui/forms.py
+++ b/python/fastui/forms.py
@@ -8,12 +8,16 @@
 import fastapi
 import pydantic
 import pydantic_core
+import typing_extensions
 from pydantic_core import core_schema
 from starlette import datastructures as ds
 
-from . import events, json_schema
+from . import events
 
-__all__ = 'FastUIForm', 'fastui_form', 'FormResponse', 'FormFile'
+if typing.TYPE_CHECKING:
+    from . import json_schema
+
+__all__ = 'FastUIForm', 'fastui_form', 'FormResponse', 'FormFile', 'SelectSearchResponse', 'SelectOption'
 
 FormModel = typing.TypeVar('FormModel', bound=pydantic.BaseModel)
 
@@ -119,12 +123,16 @@ def __get_pydantic_core_schema__(self, source_type: type[typing.Any], *_args) ->
 
         raise TypeError(f'FormFile can only be used with `UploadFile` or `list[UploadFile]`, not {source_type}')
 
-    def __get_pydantic_json_schema__(self, core_schema_: core_schema.CoreSchema, *_args) -> json_schema.JsonSchemaFile:
-        function = core_schema_.get('function', {}).get('function')
-        multiple = bool(function and function.__name__ == 'validate_multiple')
-        s = json_schema.JsonSchemaFile(type='string', format='binary', multiple=multiple)
+    def __get_pydantic_json_schema__(self, core_schema_: core_schema.CoreSchema, *_args) -> json_schema.JsonSchemaAny:
+        from . import json_schema
+
+        s = json_schema.JsonSchemaFile(type='string', format='binary')
         if self.accept:
             s['accept'] = self.accept
+
+        function = core_schema_.get('function', {}).get('function')
+        if function and function.__name__ == 'validate_multiple':
+            s = json_schema.JsonSchemaArray(type='array', items=s)
         return s
 
     def __repr__(self):
@@ -136,6 +144,20 @@ class FormResponse(pydantic.BaseModel):
     type: typing.Literal['FormResponse'] = 'FormResponse'
 
 
+class SelectOption(typing_extensions.TypedDict):
+    value: str
+    label: str
+
+
+class SelectGroup(typing_extensions.TypedDict):
+    label: str
+    options: list[SelectOption]
+
+
+class SelectSearchResponse(pydantic.BaseModel):
+    options: list[SelectOption] | list[SelectGroup]
+
+
 NestedDict: typing.TypeAlias = 'dict[str | int, NestedDict | str | list[str] | ds.UploadFile | list[ds.UploadFile]]'
 
 
diff --git a/python/fastui/json_schema.py b/python/fastui/json_schema.py
index 768f6643..f3f2eb9d 100644
--- a/python/fastui/json_schema.py
+++ b/python/fastui/json_schema.py
@@ -13,9 +13,15 @@
     FormFieldFile,
     FormFieldInput,
     FormFieldSelect,
+    FormFieldSelectSearch,
     InputHtmlType,
 )
 
+if typing.TYPE_CHECKING:
+    from .forms import SelectOption
+else:
+    SelectOption = dict
+
 __all__ = 'model_json_schema_to_fields', 'SchemeLocation'
 
 
@@ -41,20 +47,25 @@ class JsonSchemaBase(TypedDict, total=False):
 class JsonSchemaString(JsonSchemaBase):
     type: Required[Literal['string']]
     default: str
-    format: Literal['date', 'date-time', 'time', 'email', 'uri', 'uuid']
+    format: Literal['date', 'date-time', 'time', 'email', 'uri', 'uuid', 'password']
 
 
 class JsonSchemaStringEnum(JsonSchemaBase, total=False):
     type: Required[Literal['string']]
     enum: Required[list[str]]
     default: str
-    enum_display_values: dict[str, str]
+    enum_labels: dict[str, str]
+
+
+class JsonSchemaStringSearch(JsonSchemaBase, total=False):
+    type: Required[Literal['string']]
+    search_url: Required[str]
+    initial: SelectOption
 
 
 class JsonSchemaFile(JsonSchemaBase, total=False):
     type: Required[Literal['string']]
     format: Required[Literal['binary']]
-    multiple: bool
     accept: str
 
 
@@ -85,10 +96,12 @@ class JsonSchemaNumber(JsonSchemaBase, total=False):
 
 class JsonSchemaArray(JsonSchemaBase, total=False):
     type: Required[Literal['array']]
+    uniqueItems: bool
     minItems: int
     maxItems: int
     prefixItems: list[JsonSchemaAny]
     items: JsonSchemaAny
+    search_url: str
 
 
 JsonSchemaDefs = dict[str, JsonSchemaConcrete]
@@ -136,16 +149,11 @@ def json_schema_any_to_fields(
     schema: JsonSchemaAny, loc: SchemeLocation, title: list[str], required: bool, defs: JsonSchemaDefs
 ) -> Iterable[FormField]:
     schema, required = deference_json_schema(schema, defs, required)
+    title = title + [schema.get('title') or loc_to_title(loc)]
+
     if schema_is_field(schema):
         yield json_schema_field_to_field(schema, loc, title, required)
-        return
-
-    if schema_title := schema.get('title'):
-        title = title + [schema_title]
-    elif loc:
-        title = title + [loc_to_title(loc)]
-
-    if schema_is_array(schema):
+    elif schema_is_array(schema):
         yield from json_schema_array_to_fields(schema, loc, title, required, defs)
     else:
         assert schema_is_object(schema), f'Unexpected schema type {schema}'
@@ -157,7 +165,6 @@ def json_schema_field_to_field(
     schema: JsonSchemaField, loc: SchemeLocation, title: list[str], required: bool
 ) -> FormField:
     name = loc_to_name(loc)
-    title = title + [schema.get('title') or loc_to_title(loc)]
     if schema['type'] == 'boolean':
         return FormFieldCheckbox(
             name=name,
@@ -166,25 +173,8 @@ def json_schema_field_to_field(
             initial=schema.get('default'),
             description=schema.get('description'),
         )
-    elif schema['type'] == 'string' and (enum := schema.get('enum')):
-        enum_display_values = schema.get('enum_display_values', {})
-        return FormFieldSelect(
-            name=name,
-            title=title,
-            required=required,
-            choices=[(v, enum_display_values.get(v) or as_title(v)) for v in enum],
-            initial=schema.get('default'),
-            description=schema.get('description'),
-        )
-    elif schema['type'] == 'string' and schema.get('format') == 'binary':
-        return FormFieldFile(
-            name=name,
-            title=title,
-            required=required,
-            multiple=schema.get('multiple', False),
-            accept=schema.get('accept'),
-            description=schema.get('description'),
-        )
+    elif field := special_string_field(schema, name, title, required, False):
+        return field
     else:
         return FormFieldInput(
             name=name,
@@ -202,10 +192,58 @@ def loc_to_title(loc: SchemeLocation) -> str:
 
 def json_schema_array_to_fields(
     schema: JsonSchemaArray, loc: SchemeLocation, title: list[str], required: bool, defs: JsonSchemaDefs
-) -> list[FormField]:
+) -> Iterable[FormField]:
+    items_schema = schema.get('items')
+    if items_schema:
+        items_schema, required = deference_json_schema(items_schema, defs, required)
+        if search_url := schema.get('search_url'):
+            items_schema['search_url'] = search_url  # type: ignore
+        if field := special_string_field(items_schema, loc_to_name(loc), title, required, True):
+            return [field]
     raise NotImplementedError('todo')
 
 
+def special_string_field(
+    schema: JsonSchemaConcrete, name: str, title: list[str], required: bool, multiple: bool
+) -> FormField | None:
+    if schema['type'] == 'string':
+        if schema.get('format') == 'binary':
+            return FormFieldFile(
+                name=name,
+                title=title,
+                required=required,
+                multiple=multiple,
+                accept=schema.get('accept'),
+                description=schema.get('description'),
+            )
+        elif enum := schema.get('enum'):
+            enum_labels = schema.get('enum_labels', {})
+            return FormFieldSelect(
+                name=name,
+                title=title,
+                required=required,
+                multiple=multiple,
+                options=[SelectOption(value=v, label=enum_labels.get(v) or as_title(v)) for v in enum],
+                initial=schema.get('default'),
+                description=schema.get('description'),
+            )
+        elif search_url := schema.get('search_url'):
+            return FormFieldSelectSearch(
+                search_url=search_url,
+                name=name,
+                title=title,
+                required=required,
+                multiple=multiple,
+                initial=schema.get('initial'),
+                description=schema.get('description'),
+            )
+
+
+def select_options(schema: JsonSchemaStringEnum) -> list[SelectOption]:
+    enum_labels = schema.get('enum_labels', {})
+    return [SelectOption(value=v, label=enum_labels.get(v) or as_title(v)) for v in schema['enum']]
+
+
 def loc_to_name(loc: SchemeLocation) -> str:
     """
     Convert a loc to a string if any item contains a '.' or the first item starts with '[' then encode with JSON,
@@ -238,6 +276,11 @@ def deference_json_schema(
         if len(any_of) == 2 and sum(s.get('type') == 'null' for s in any_of) == 1:
             # If anyOf is a single type and null, then it is optional
             not_null_schema = next(s for s in any_of if s.get('type') != 'null')
+
+            # is there anything else apart from `default` we need to copy over?
+            if default := schema.get('default'):
+                not_null_schema['default'] = default  # type: ignore
+
             return deference_json_schema(not_null_schema, defs, False)
         else:
             raise NotImplementedError('`anyOf` schemas which are not simply `X | None` are not yet supported')