Bootstrap (#5)

This commit is contained in:
Samuel Colvin
2023-11-24 10:28:20 +00:00
committed by GitHub
parent a2dacd04a4
commit e06fddf29e
39 changed files with 953 additions and 333 deletions

View File

@@ -12,7 +12,7 @@ module.exports = {
parser: '@typescript-eslint/parser',
plugins: ['react', '@typescript-eslint', 'react-refresh', 'simple-import-sort'],
rules: {
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'react-refresh/only-export-components': 'off', // how much effect does this have?
'@typescript-eslint/no-explicit-any': 'off',
'no-use-before-define': 'off',
'import/order': [

228
package-lock.json generated
View File

@@ -33,6 +33,17 @@
"node": ">=0.10.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.23.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.4.tgz",
"integrity": "sha512-2Yv65nlWnWlSpe3fXEyX5i7fx5kIKo4Qbcj+hMO0odwaneFjfXw5fdum+4yL20O0QiaHpia0cYQ9xpNMqrBwHg==",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.19.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.5.tgz",
@@ -513,12 +524,64 @@
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@react-aria/ssr": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.0.tgz",
"integrity": "sha512-Bz6BqP6ZorCme9tSWHZVmmY+s7AU8l6Vl2NUYmBzezD//fVHHfFo4lFBn5tBuAaJEm3AuCLaJQ6H2qhxNSb7zg==",
"dependencies": {
"@swc/helpers": "^0.5.0"
},
"engines": {
"node": ">= 12"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
}
},
"node_modules/@restart/hooks": {
"version": "0.4.11",
"resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.11.tgz",
"integrity": "sha512-Ft/ncTULZN6ldGHiF/k5qt72O8JyRMOeg0tApvCni8LkoiEahO+z3TNxfXIVGy890YtWVDvJAl662dVJSJXvMw==",
"dependencies": {
"dequal": "^2.0.3"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@restart/ui": {
"version": "1.6.6",
"resolved": "https://registry.npmjs.org/@restart/ui/-/ui-1.6.6.tgz",
"integrity": "sha512-eC3puKuWE1SRYbojWHXnvCNHGgf3uzHCb6JOhnF4OXPibOIPEkR1sqDSkL643ydigxwh+ruCa1CmYHlzk7ikKA==",
"dependencies": {
"@babel/runtime": "^7.21.0",
"@popperjs/core": "^2.11.6",
"@react-aria/ssr": "^3.5.0",
"@restart/hooks": "^0.4.9",
"@types/warning": "^3.0.0",
"dequal": "^2.0.3",
"dom-helpers": "^5.2.0",
"uncontrollable": "^8.0.1",
"warning": "^4.0.3"
},
"peerDependencies": {
"react": ">=16.14.0",
"react-dom": ">=16.14.0"
}
},
"node_modules/@restart/ui/node_modules/uncontrollable": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-8.0.4.tgz",
"integrity": "sha512-ulRWYWHvscPFc0QQXvyJjY6LIXU56f0h8pQFvhxiKk5V1fcI8gp9Ht9leVAhrVjzqMw0BgjspBINx9r6oyJUvQ==",
"peerDependencies": {
"react": ">=16.14.0"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.5.0.tgz",
@@ -867,6 +930,14 @@
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.2.tgz",
"integrity": "sha512-9F4ys4C74eSTEUNndnER3VJ15oru2NumfQxS8geE+f3eB5xvfxpWyqE5XlVnxb/R14uoXi6SLbBwwiDSkv+XEw=="
},
"node_modules/@swc/helpers": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.3.tgz",
"integrity": "sha512-FaruWX6KdudYloq1AHD/4nU+UsMTdNE8CKyrseXWEcgjDAbvkwJg2QGPAnfIJLIWsjZOSPLOAykK6fuYp4vp4A==",
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@swc/types": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.5.tgz",
@@ -897,14 +968,12 @@
"node_modules/@types/prop-types": {
"version": "15.7.10",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.10.tgz",
"integrity": "sha512-mxSnDQxPqsZxmeShFH+uwQ4kO4gcJcGahjjMFeLbKE95IAZiiZyiEepGZjtXJ7hN/yfu0bu9xN2ajcU0JcxX6A==",
"dev": true
"integrity": "sha512-mxSnDQxPqsZxmeShFH+uwQ4kO4gcJcGahjjMFeLbKE95IAZiiZyiEepGZjtXJ7hN/yfu0bu9xN2ajcU0JcxX6A=="
},
"node_modules/@types/react": {
"version": "18.2.37",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.37.tgz",
"integrity": "sha512-RGAYMi2bhRgEXT3f4B92WTohopH6bIXw05FuGlmJEnv/omEn190+QYEIYxIAuIBdKgboYYdVved2p1AxZVQnaw==",
"dev": true,
"dependencies": {
"@types/prop-types": "*",
"@types/scheduler": "*",
@@ -920,11 +989,18 @@
"@types/react": "*"
}
},
"node_modules/@types/react-transition-group": {
"version": "4.4.9",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.9.tgz",
"integrity": "sha512-ZVNmWumUIh5NhH8aMD9CR2hdW0fNuYInlocZHaZ+dgk/1K49j1w/HoAuK1ki+pgscQrOFRTlXeoURtuzEkV3dg==",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/scheduler": {
"version": "0.16.6",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.6.tgz",
"integrity": "sha512-Vlktnchmkylvc9SnwwwozTv04L/e1NykF5vgoQ0XTmI8DD+wxfjQuHuvHS3p0r2jz2x2ghPs2h1FVeDirIteWA==",
"dev": true
"integrity": "sha512-Vlktnchmkylvc9SnwwwozTv04L/e1NykF5vgoQ0XTmI8DD+wxfjQuHuvHS3p0r2jz2x2ghPs2h1FVeDirIteWA=="
},
"node_modules/@types/semver": {
"version": "7.5.5",
@@ -932,6 +1008,11 @@
"integrity": "sha512-+d+WYC1BxJ6yVOgUgzK8gWvp5qF8ssV5r4nsDcZWKRWcDQLQ619tvWAxJQYGgBrO1MnLJC7a5GtiYsAoQ47dJg==",
"dev": true
},
"node_modules/@types/warning": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz",
"integrity": "sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q=="
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.11.0.tgz",
@@ -1521,6 +1602,11 @@
"node": ">= 6"
}
},
"node_modules/classnames": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz",
"integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw=="
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1562,8 +1648,7 @@
"node_modules/csstype": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz",
"integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==",
"dev": true
"integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ=="
},
"node_modules/debug": {
"version": "4.3.4",
@@ -1619,6 +1704,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"engines": {
"node": ">=6"
}
},
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@@ -1643,6 +1736,15 @@
"node": ">=6.0.0"
}
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/es-abstract": {
"version": "1.22.3",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.3.tgz",
@@ -2744,6 +2846,14 @@
"node": ">= 0.4"
}
},
"node_modules/invariant": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
"integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/is-array-buffer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz",
@@ -3321,7 +3431,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
@@ -3617,13 +3726,24 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/prop-types-extra": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz",
"integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==",
"dependencies": {
"react-is": "^16.3.2",
"warning": "^4.0.0"
},
"peerDependencies": {
"react": ">=0.14.0"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -3664,6 +3784,35 @@
"node": ">=0.10.0"
}
},
"node_modules/react-bootstrap": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.9.1.tgz",
"integrity": "sha512-ezgmh/ARCYp18LbZEqPp0ppvy+ytCmycDORqc8vXSKYV3cer4VH7OReV8uMOoKXmYzivJTxgzGHalGrHamryHA==",
"dependencies": {
"@babel/runtime": "^7.22.5",
"@restart/hooks": "^0.4.9",
"@restart/ui": "^1.6.6",
"@types/react-transition-group": "^4.4.6",
"classnames": "^2.3.2",
"dom-helpers": "^5.2.1",
"invariant": "^2.2.4",
"prop-types": "^15.8.1",
"prop-types-extra": "^1.1.0",
"react-transition-group": "^4.4.5",
"uncontrollable": "^7.2.1",
"warning": "^4.0.3"
},
"peerDependencies": {
"@types/react": ">=16.14.8",
"react": ">=16.14.0",
"react-dom": ">=16.14.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-dom": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
@@ -3679,8 +3828,27 @@
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/react-lifecycles-compat": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
@@ -3713,6 +3881,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/regenerator-runtime": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz",
"integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA=="
},
"node_modules/regexp.prototype.flags": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz",
@@ -4160,6 +4333,11 @@
"strip-bom": "^3.0.0"
}
},
"node_modules/tslib": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -4277,6 +4455,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/uncontrollable": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz",
"integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==",
"dependencies": {
"@babel/runtime": "^7.6.3",
"@types/react": ">=16.9.11",
"invariant": "^2.2.4",
"react-lifecycles-compat": "^3.0.4"
},
"peerDependencies": {
"react": ">=15.0.0"
}
},
"node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
@@ -4351,6 +4543,14 @@
}
}
},
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -4478,8 +4678,12 @@
"dependencies": {
"bootstrap": "^5.3.2",
"react": "^18.2.0",
"react-bootstrap": "^2.9.1",
"react-dom": "^18.2.0",
"sass": "^1.69.5"
},
"peerDependencies": {
"fastui": "0.0.0"
}
},
"packages/vanilla": {

View File

@@ -9,6 +9,7 @@
"dependencies": {
"bootstrap": "^5.3.2",
"react": "^18.2.0",
"react-bootstrap": "^2.9.1",
"react-dom": "^18.2.0",
"sass": "^1.69.5"
},

View File

@@ -1,27 +1,90 @@
import { ClassNameGenerator, CustomRender } from 'fastui'
import { pathMatch } from 'fastui'
import type { components, ClassNameGenerator, CustomRender, ClassName } from 'fastui'
import { Modal } from './modal'
import { Navbar } from './navbar'
export const customRender: CustomRender = (props) => {
const { type } = props
if (type === 'DisplayPrimitive') {
const { value } = props
if (typeof value === 'boolean') {
return () => <>{value ? '👍' : '👎'}</>
}
switch (type) {
case 'DisplayPrimitive':
return displayPrimitiveRender(props)
case 'Navbar':
return () => <Navbar {...props} />
case 'Modal':
return () => <Modal {...props} />
}
}
export const classNameGenerator: ClassNameGenerator = (props) => {
function displayPrimitiveRender(props: components.DisplayPrimitiveProps) {
const { value } = props
if (typeof value === 'boolean') {
return () => <>{value ? '👍' : '👎'}</>
}
}
export const classNameGenerator: ClassNameGenerator = ({ props, fullPath, subElement }) => {
const { type } = props
switch (type) {
case 'Page':
return 'container py-4'
case 'Row':
return 'row'
case 'Col':
return 'col'
case 'Button':
return 'btn btn-primary'
case 'Table':
return 'table table-striped'
case 'Form':
case 'ModelForm':
return formClassName(subElement)
case 'FormFieldInput':
case 'FormFieldCheckbox':
case 'FormFieldSelect':
case 'FormFieldFile':
return formFieldClassName(props, subElement)
case 'Navbar':
return navbarClassName(subElement)
case 'Link':
return linkClassName(props, fullPath)
}
}
function formFieldClassName(props: components.FormFieldProps, subElement?: string): ClassName {
switch (subElement) {
case 'input':
return props.error ? 'is-invalid form-control' : 'form-control'
case 'select':
return 'form-select'
case 'label':
return { 'form-label': true, 'fw-bold': props.required }
case 'error':
return 'invalid-feedback'
case 'description':
return 'form-text'
default:
return 'mb-3'
}
}
function formClassName(subElement?: string): ClassName {
switch (subElement) {
case 'form-container':
return 'row justify-content-center'
default:
return 'col-md-4'
}
}
function navbarClassName(subElement?: string): ClassName {
switch (subElement) {
case 'contents':
return 'container'
case 'title':
return 'navbar-brand'
default:
return 'navbar navbar-expand-lg bg-body-tertiary'
}
}
function linkClassName(props: components.LinkProps, fullPath: string): ClassName {
return { active: pathMatch(props.active, fullPath), 'nav-link': props.mode === 'navbar' }
}

View File

@@ -0,0 +1,25 @@
import { FC } from 'react'
import { components, events, renderClassName } from 'fastui'
import BootstrapModal from 'react-bootstrap/Modal'
export const Modal: FC<components.ModalProps> = (props) => {
const { className, title, body, footer, openTrigger } = props
const [open, toggle] = events.useEventListenerToggle(openTrigger, props.open)
return (
<BootstrapModal className={renderClassName(className)} show={open} onHide={toggle}>
<BootstrapModal.Header closeButton>
<BootstrapModal.Title>{title}</BootstrapModal.Title>
</BootstrapModal.Header>
<BootstrapModal.Body>
<components.AnyCompList propsList={body} />
</BootstrapModal.Body>
{footer && (
<BootstrapModal.Footer className="modal-footer">
<components.AnyCompList propsList={footer} />
</BootstrapModal.Footer>
)}
</BootstrapModal>
)
}

View File

@@ -0,0 +1,43 @@
import { FC } from 'react'
import { components, useClassName } from 'fastui'
import BootstrapNavbar from 'react-bootstrap/Navbar'
export const Navbar: FC<components.NavbarProps> = (props) => {
const links = props.links.map((link) => {
link.mode = link.mode || 'navbar'
return link
})
return (
<BootstrapNavbar expand="lg" className="bg-body-tertiary">
<div className={useClassName(props, { el: 'contents' })}>
<NavbarTitle {...props} />
<BootstrapNavbar.Toggle aria-controls="navbar-collapse" />
<BootstrapNavbar.Collapse id="navbar-collapse">
<ul className="navbar-nav me-auto">
{links.map((link, i) => (
<li className="nav-item">
<components.LinkComp key={i} {...link} />
</li>
))}
</ul>
</BootstrapNavbar.Collapse>
</div>
</BootstrapNavbar>
)
}
const NavbarTitle = (props: components.NavbarProps) => {
const { title, titleEvent } = props
const className = useClassName(props, { el: 'title' })
if (title) {
if (titleEvent) {
return (
<components.LinkRender onClick={titleEvent} className={className}>
{title}
</components.LinkRender>
)
} else {
return <span className={className}>{title}</span>
}
}
}

View File

@@ -1,6 +1,6 @@
import { FC, useState } from 'react'
import { ClassName, useClassNameGenerator } from '../hooks/className'
import { ClassName, useClassName } from '../hooks/className'
interface BaseFormFieldProps {
name: string
@@ -8,6 +8,7 @@ interface BaseFormFieldProps {
required: boolean
locked: boolean
error?: string
description?: string
className?: ClassName
}
@@ -15,29 +16,31 @@ export type FormFieldProps = FormFieldInputProps | FormFieldCheckboxProps | Form
interface FormFieldInputProps extends BaseFormFieldProps {
type: 'FormFieldInput'
htmlType?: 'text' | 'date' | 'datetime-local' | 'time' | 'email' | 'url' | 'file' | 'number'
htmlType?: 'text' | 'date' | 'datetime-local' | 'time' | 'email' | 'url' | 'number' | 'password'
initial?: string | number
placeholder?: string
}
export const FormFieldInputComp: FC<FormFieldInputProps> = (props) => {
const { className, name, title, required, htmlType, locked } = props
const { name, placeholder, required, htmlType, locked } = props
const [value, setValue] = useState(props.initial ?? '')
// TODO placeholder
return (
<div className={useClassNameGenerator(className, props)}>
<label htmlFor={name}>
<Title title={title} />
</label>
<div className={useClassName(props)}>
<Label {...props} />
<input
type={htmlType}
className={useClassName(props, { el: 'input' })}
value={value}
onChange={(e) => setValue(e.target.value)}
id={inputId(props)}
name={name}
required={required}
disabled={locked}
placeholder={placeholder}
aria-describedby={descId(props)}
/>
{props.error ? <div>Error: {props.error}</div> : null}
<ErrorDescription {...props} />
</div>
)
}
@@ -48,14 +51,22 @@ interface FormFieldCheckboxProps extends BaseFormFieldProps {
}
export const FormFieldCheckboxComp: FC<FormFieldCheckboxProps> = (props) => {
const { className, name, title, required, locked } = props
const { name, required, locked } = props
return (
<div className={useClassNameGenerator(className, props)}>
<label htmlFor={name}>
<Title title={title} />
</label>
<input type="checkbox" defaultChecked={!!props.initial} name={name} required={required} disabled={locked} />
{props.error ? <div>Error: {props.error}</div> : null}
<div className={useClassName(props)}>
<Label {...props} />
<input
type="checkbox"
className={useClassName(props, { el: 'input' })}
defaultChecked={!!props.initial}
id={inputId(props)}
name={name}
required={required}
disabled={locked}
aria-describedby={descId(props)}
/>
<ErrorDescription {...props} />
</div>
)
}
@@ -67,20 +78,21 @@ interface FormFieldSelectProps extends BaseFormFieldProps {
}
export const FormFieldSelectComp: FC<FormFieldSelectProps> = (props) => {
const { className, name, title, required, locked, choices } = props
const { name, required, locked, choices } = props
const [value, setValue] = useState(props.initial ?? '')
return (
<div className={useClassNameGenerator(className, props)}>
<label htmlFor={name}>
<Title title={title} />
</label>
<div className={useClassName(props)}>
<Label {...props} />
<select
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]) => (
@@ -89,7 +101,7 @@ export const FormFieldSelectComp: FC<FormFieldSelectProps> = (props) => {
</option>
))}
</select>
{props.error ? <div>Error: {props.error}</div> : null}
<ErrorDescription {...props} />
</div>
)
}
@@ -101,27 +113,54 @@ interface FormFieldFileProps extends BaseFormFieldProps {
}
export const FormFieldFileComp: FC<FormFieldFileProps> = (props) => {
const { className, name, title, required, locked, multiple, accept } = props
const { name, required, locked, multiple, accept } = props
return (
<div className={useClassNameGenerator(className, props)}>
<label htmlFor={name}>
<Title title={title} />
</label>
<input type="file" name={name} required={required} disabled={locked} multiple={multiple} accept={accept} />
{props.error ? <div>Error: {props.error}</div> : null}
<div className={useClassName(props)}>
<Label {...props} />
<input
type="file"
className={useClassName(props, { el: 'input' })}
id={inputId(props)}
name={name}
required={required}
disabled={locked}
multiple={multiple}
accept={accept}
/>
<ErrorDescription {...props} />
</div>
)
}
const Title: FC<{ title: string[] }> = ({ title }) => {
const Label: FC<FormFieldProps> = (props) => {
const { title } = props
return (
<>
<label htmlFor={inputId(props)} className={useClassName(props, { el: 'label' })}>
{title.map((t, i) => (
<span key={i}>
{i > 0 ? <> &rsaquo;</> : null} {t}
</span>
))}
</label>
)
}
const inputId = (props: FormFieldProps) => `form-field-${props.name}`
const descId = (props: FormFieldProps) => (props.description ? `${inputId(props)}-desc` : undefined)
const ErrorDescription: FC<FormFieldProps> = (props) => {
const { description, error } = props
const descClassName = useClassName(props, { el: 'description' })
const errorClassName = useClassName(props, { el: 'error' })
return (
<>
{description ? (
<div id={descId(props)} className={descClassName}>
{description}
</div>
) : null}
{error ? <div className={errorClassName}>{error}</div> : null}
</>
)
}

View File

@@ -1,10 +1,13 @@
import { FC } from 'react'
import { ClassName } from '../hooks/className'
export type JsonData = string | number | boolean | null | JsonData[] | { [key: string]: JsonData }
export interface JsonProps {
value: JsonData
type: 'JSON'
className?: ClassName
}
export const JsonComp: FC<JsonProps> = ({ value }) => {

View File

@@ -0,0 +1,18 @@
import { ClassName, useClassName } from '../hooks/className'
import { LinkProps, LinkComp } from './link'
export interface LinkListProps {
type: 'LinkList'
links: LinkProps[]
mode?: 'tabs' | 'vertical'
className?: ClassName
}
export const LinkListComp = (props: LinkListProps) => (
<div className={useClassName(props)}>
{props.links.map((link, i) => (
<LinkComp key={i} {...link} />
))}
</div>
)

View File

@@ -0,0 +1,16 @@
import { FC, useEffect } from 'react'
export interface PageTitleProps {
type: 'PageTitle'
text: string
}
export const PageTitleComp: FC<PageTitleProps> = (props) => {
const { text } = props
useEffect(() => {
document.title = text
}, [text])
return <></>
}

View File

@@ -6,7 +6,7 @@ import { request } from '../tools'
import { DefaultLoading } from '../DefaultLoading'
import { ConfigContext } from '../hooks/config'
import { AnyComp, FastProps } from './index'
import { AnyCompList, FastProps } from './index'
export interface ServerLoadProps {
type: 'ServerLoad'
@@ -14,7 +14,7 @@ export interface ServerLoadProps {
}
export const ServerLoadComp: FC<ServerLoadProps> = ({ url }) => {
const [componentProps, setComponentProps] = useState<FastProps | null>(null)
const [componentProps, setComponentProps] = useState<FastProps[] | null>(null)
const { error, setError } = useContext(ErrorContext)
const reloadValue = useContext(ReloadContext)
@@ -32,7 +32,7 @@ export const ServerLoadComp: FC<ServerLoadProps> = ({ url }) => {
const promise = request({ url: fetchUrl })
promise
.then(([, data]) => setComponentProps(data as FastProps))
.then(([, data]) => setComponentProps(data as FastProps[]))
.catch((e) => {
setError({ title: 'Request Error', description: e.message })
})
@@ -50,6 +50,6 @@ export const ServerLoadComp: FC<ServerLoadProps> = ({ url }) => {
return <DefaultLoading />
}
} else {
return <AnyComp {...componentProps} />
return <AnyCompList propsList={componentProps} />
}
}

View File

@@ -1,23 +1,23 @@
import { FC } from 'react'
import { ClassName, useClassNameGenerator } from '../hooks/className'
import { useFireEvent, PageEvent, GoToEvent } from '../hooks/event'
import { ClassName, useClassName } from '../hooks/className'
import { useFireEvent, AnyEvent } from '../hooks/events'
export interface ButtonProps {
type: 'Button'
text: string
onClick?: PageEvent | GoToEvent
onClick?: AnyEvent
htmlType?: 'button' | 'submit' | 'reset'
className?: ClassName
}
export const ButtonComp: FC<ButtonProps> = (props) => {
const { className, text, onClick, htmlType } = props
const { text, onClick, htmlType } = props
const { fireEvent } = useFireEvent()
return (
<button className={useClassNameGenerator(className, props)} type={htmlType} onClick={() => fireEvent(onClick)}>
<button className={useClassName(props)} type={htmlType} onClick={() => fireEvent(onClick)}>
{text}
</button>
)

View File

@@ -81,7 +81,7 @@ export const DisplayObject: FC<DisplayObjectProps> = (props) => {
type JSONPrimitive = string | number | boolean | null
interface DisplayPrimitiveProps {
export interface DisplayPrimitiveProps {
value: JSONPrimitive
display: DisplayChoices
type: 'DisplayPrimitive'

View File

@@ -1,44 +1,25 @@
import { FC } from 'react'
import { ClassName, useClassNameGenerator } from '../hooks/className'
import { ClassName, useClassName } from '../hooks/className'
import { FastProps, RenderChildren } from './index'
import { FastProps, AnyCompList } from './index'
interface DivProps {
export interface DivProps {
type: 'Div'
children: FastProps[]
components: FastProps[]
className?: ClassName
}
interface PageProps {
type: 'Page'
children: FastProps[]
components: FastProps[]
className?: ClassName
}
interface RowProps {
type: 'Row'
children: FastProps[]
className?: ClassName
}
export type AllDivProps = DivProps | PageProps
interface ColProps {
type: 'Col'
children: FastProps[]
className?: ClassName
}
export type AllDivProps = DivProps | PageProps | RowProps | ColProps
type AllDivTypes = 'Div' | 'Page' | 'Row' | 'Col'
interface Props {
type: AllDivTypes
children: FastProps[]
className?: ClassName
}
export const DivComp: FC<Props> = (props) => (
<div className={useClassNameGenerator(props.className, props)}>
<RenderChildren children={props.children} />
export const DivComp: FC<AllDivProps> = (props) => (
<div className={useClassName(props)}>
<AnyCompList propsList={props.components} />
</div>
)

View File

@@ -1,10 +1,10 @@
import { FC, FormEvent, useState } from 'react'
import { ClassName, useClassNameGenerator } from '../hooks/className'
import { useFireEvent, PageEvent, GoToEvent } from '../hooks/event'
import { ClassName, useClassName } from '../hooks/className'
import { useFireEvent, AnyEvent } from '../hooks/events'
import { request } from '../tools'
import { FastProps, RenderChildren } from './index'
import { FastProps, AnyCompList } from './index'
import { ButtonComp } from './button'
import { FormFieldProps } from './FormField'
@@ -26,11 +26,11 @@ export interface ModelFormProps extends BaseFormProps {
interface FormResponse {
type: 'FormResponse'
event: PageEvent | GoToEvent
event: AnyEvent
}
export const FormComp: FC<FormProps | ModelFormProps> = (props) => {
const { className, formFields, submitUrl, footer } = props
const { formFields, submitUrl, footer } = props
// mostly equivalent to `<input disabled`
const [locked, setLocked] = useState(false)
@@ -71,11 +71,13 @@ export const FormComp: FC<FormProps | ModelFormProps> = (props) => {
)
return (
<form className={useClassNameGenerator(className, props)} onSubmit={onSubmit}>
<RenderChildren children={fieldProps} />
{error ? <div>Error: {error}</div> : null}
<Footer footer={footer} />
</form>
<div className={useClassName(props, { el: 'form-container' })}>
<form className={useClassName(props)} onSubmit={onSubmit}>
<AnyCompList propsList={fieldProps} />
{error ? <div>Error: {error}</div> : null}
<Footer footer={footer} />
</form>
</div>
)
}
@@ -85,7 +87,7 @@ const Footer: FC<{ footer?: boolean | FastProps[] }> = ({ footer }) => {
} else if (footer === true || typeof footer === 'undefined') {
return <ButtonComp type="Button" text="Submit" htmlType="submit" />
} else {
return <RenderChildren children={footer} />
return <AnyCompList propsList={footer} />
}
}

View File

@@ -1,6 +1,6 @@
import { FC } from 'react'
import { ClassName, useClassNameGenerator } from '../hooks/className'
import { ClassName, useClassName } from '../hooks/className'
export interface HeadingProps {
type: 'Heading'
@@ -10,12 +10,12 @@ export interface HeadingProps {
}
export const HeadingComp: FC<HeadingProps> = (props) => {
const { level, text, className } = props
const { level, text } = props
const HeadingComponent = getComponent(level)
return <HeadingComponent text={text} className={useClassNameGenerator(className, props)} />
return <HeadingComponent text={text} className={useClassName(props)} />
}
function getComponent(level: 1 | 2 | 3 | 4 | 5 | 6): FC<{ text: string; className: string }> {
function getComponent(level: 1 | 2 | 3 | 4 | 5 | 6): FC<{ text: string; className?: string }> {
switch (level) {
case 1:
return ({ text, className }) => <h1 className={className}>{text}</h1>

View File

@@ -4,8 +4,10 @@ import { ErrorContext } from '../hooks/error'
import { useCustomRender } from '../hooks/config'
import { unreachable } from '../tools'
import { AllDivProps, DivComp } from './div'
import { TextProps, TextComp } from './text'
import { ParagraphProps, ParagraphComp } from './paragraph'
import { PageTitleProps, PageTitleComp } from './PageTitle'
import { AllDivProps, DivComp, DivProps } from './div'
import { HeadingComp, HeadingProps } from './heading'
import { FormComp, FormProps, ModelFormProps } from './form'
import {
@@ -16,16 +18,53 @@ import {
FormFieldFileComp,
} from './FormField'
import { ButtonComp, ButtonProps } from './button'
import { LinkComp, LinkProps } from './link'
import { LinkComp, LinkProps, LinkRender } from './link'
import { LinkListProps, LinkListComp } from './LinkList'
import { NavbarProps, NavbarComp } from './navbar'
import { ModalComp, ModalProps } from './modal'
import { TableComp, TableProps } from './table'
import { AllDisplayProps, DisplayArray, DisplayComp, DisplayObject, DisplayPrimitive } from './display'
import {
AllDisplayProps,
DisplayArray,
DisplayComp,
DisplayObject,
DisplayPrimitive,
DisplayPrimitiveProps,
} from './display'
import { JsonComp, JsonProps } from './Json'
import { ServerLoadComp, ServerLoadProps } from './ServerLoad'
export type {
TextProps,
ParagraphProps,
PageTitleProps,
AllDivProps,
DivProps,
HeadingProps,
FormProps,
ModelFormProps,
FormFieldProps,
ButtonProps,
ModalProps,
TableProps,
LinkProps,
LinkListProps,
NavbarProps,
AllDisplayProps,
DisplayPrimitiveProps,
JsonProps,
ServerLoadProps,
}
// TODO some better way to export components
export { LinkComp, LinkRender }
export type FastProps =
| TextProps
| ParagraphProps
| PageTitleProps
| AllDivProps
| DivProps
| HeadingProps
| FormProps
| ModelFormProps
@@ -34,10 +73,22 @@ export type FastProps =
| ModalProps
| TableProps
| LinkProps
| LinkListProps
| NavbarProps
| AllDisplayProps
| JsonProps
| ServerLoadProps
export type FastClassNameProps = Exclude<FastProps, TextProps | AllDisplayProps | ServerLoadProps | PageTitleProps>
export const AnyCompList: FC<{ propsList: FastProps[] }> = ({ propsList }) => (
<>
{propsList.map((child, i) => (
<AnyComp key={i} {...child} />
))}
</>
)
export const AnyComp: FC<FastProps> = (props) => {
const { DisplayError } = useContext(ErrorContext)
@@ -51,17 +102,23 @@ export const AnyComp: FC<FastProps> = (props) => {
switch (type) {
case 'Text':
return <TextComp {...props} />
case 'Paragraph':
return <ParagraphComp {...props} />
case 'PageTitle':
return <PageTitleComp {...props} />
case 'Div':
case 'Page':
case 'Row':
case 'Col':
return renderWithChildren(DivComp, props)
return <DivComp {...props} />
case 'Heading':
return <HeadingComp {...props} />
case 'Button':
return <ButtonComp {...props} />
case 'Link':
return renderWithChildren(LinkComp, props)
return <LinkComp {...props} />
case 'LinkList':
return <LinkListComp {...props} />
case 'Navbar':
return <NavbarComp {...props} />
case 'Form':
case 'ModelForm':
return <FormComp {...props} />
@@ -99,21 +156,3 @@ export const AnyComp: FC<FastProps> = (props) => {
return <DisplayError title="Render Error" description={description} />
}
}
interface WithChildren {
children: FastProps[]
}
function renderWithChildren<T extends WithChildren>(Component: FC<T>, props: T) {
const { children, ...rest } = props
// TODO is there a way to make this type safe?
return <Component {...(rest as any)}>{children}</Component>
}
export const RenderChildren: FC<{ children: FastProps[] }> = ({ children }) => (
<>
{children.map((child, i) => (
<AnyComp key={i} {...child} />
))}
</>
)

View File

@@ -1,26 +1,30 @@
import { FC, MouseEventHandler, ReactNode } from 'react'
import { ClassName, useClassNameGenerator } from '../hooks/className'
import { useFireEvent, PageEvent, GoToEvent } from '../hooks/event'
import { ClassName, useClassName } from '../hooks/className'
import { useFireEvent, AnyEvent } from '../hooks/events'
import { FastProps, RenderChildren } from './index'
import { FastProps, AnyCompList } from './index'
export interface LinkProps {
type: 'Link'
children: FastProps[]
onClick?: PageEvent | GoToEvent
components: FastProps[]
mode?: 'navbar' | 'tabs' | 'vertical'
active?: boolean | string
onClick?: AnyEvent
className?: ClassName
}
export const LinkComp: FC<LinkProps> = (props) => (
<LinkRender className={useClassNameGenerator(props.className, props)} onClick={props.onClick}>
<RenderChildren children={props.children} />
<LinkRender className={useClassName(props)} onClick={props.onClick}>
<AnyCompList propsList={props.components} />
</LinkRender>
)
interface LinkRenderProps {
children: ReactNode
onClick?: PageEvent | GoToEvent
mode?: 'navbar' | 'tabs' | 'vertical'
active?: boolean | string
onClick?: AnyEvent
className?: string
}

View File

@@ -1,49 +0,0 @@
.fu-modal-overlay {
position: fixed;
display: none;
z-index: 1;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.4);
}
.fu-modal-overlay.open {
display: block;
}
.fu-modal-content {
background: white;
margin: 100px auto;
border: 1px solid #888;
width: 95%;
max-width: 500px;
border-radius: 8px;
}
.fu-model-header {
display: flex;
justify-content: space-between;
padding: 10px 20px;
border-bottom: 1px solid #ccc;
}
.fu-close {
color: #aaa;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}
.fu-modal-body {
padding: 20px;
}
.fu-modal-footer {
display: flex;
justify-content: right;
padding: 10px 20px 20px;
border-top: 1px solid #ccc;
}

View File

@@ -1,10 +1,9 @@
import { FC } from 'react'
import { FC, useEffect } from 'react'
import { ClassName, renderClassName, useClassNameGenerator } from '../hooks/className'
import { PageEvent, useEventListenerToggle } from '../hooks/event'
import { ClassName } from '../hooks/className'
import { PageEvent, useEventListenerToggle } from '../hooks/events'
import { FastProps, RenderChildren } from './index'
import './modal.css'
import { FastProps } from './index'
export interface ModalProps {
type: 'Modal'
@@ -17,28 +16,18 @@ export interface ModalProps {
}
export const ModalComp: FC<ModalProps> = (props) => {
const { title, body, footer, openTrigger, className } = props
const { title, openTrigger } = props
const [open, toggle] = useEventListenerToggle(openTrigger, props.open)
return (
<div className={renderClassName({ 'fu-modal-overlay': true, open })}>
<div className={useClassNameGenerator(className, props, 'fu-modal-content')}>
<div className="fu-model-header">
<h2>{title}</h2>
<div className="fu-close" onClick={toggle}>
&times;
</div>
</div>
<div className="fu-modal-body">
<RenderChildren children={body} />
</div>
{footer && (
<div className="fu-modal-footer">
<RenderChildren children={footer} />
</div>
)}
</div>
</div>
)
useEffect(() => {
if (open) {
setTimeout(() => {
alert(`${title}\n\nNote: modals are not implemented by pure FastUI, implement a component for 'ModalProps'.`)
toggle()
})
}
}, [open, title, toggle])
return <></>
}

View File

@@ -0,0 +1,45 @@
import { ClassName, useClassName } from '../hooks/className'
import { AnyEvent } from '../hooks/events'
import { LinkProps, LinkComp, LinkRender } from './link'
export interface NavbarProps {
type: 'Navbar'
title?: string
titleEvent?: AnyEvent
links: LinkProps[]
className?: ClassName
}
export const NavbarComp = (props: NavbarProps) => {
const links = props.links.map((link) => {
link.mode = link.mode || 'navbar'
return link
})
return (
<nav className={useClassName(props)}>
<div className={useClassName(props, { el: 'contents' })}>
<NavbarTitle {...props} />
{links.map((link, i) => (
<LinkComp key={i} {...link} />
))}
</div>
</nav>
)
}
const NavbarTitle = (props: NavbarProps) => {
const { title, titleEvent } = props
const className = useClassName(props, { el: 'title' })
if (title) {
if (titleEvent) {
return (
<LinkRender onClick={titleEvent} className={className}>
{title}
</LinkRender>
)
} else {
return <span className={className}>{title}</span>
}
}
}

View File

@@ -0,0 +1,15 @@
import { FC } from 'react'
import { ClassName, useClassName } from '../hooks/className'
export interface ParagraphProps {
type: 'Paragraph'
text: string
className?: ClassName
}
export const ParagraphComp: FC<ParagraphProps> = (props) => {
const { text } = props
return <p className={useClassName(props)}>{text}</p>
}

View File

@@ -3,8 +3,8 @@ import { FC } from 'react'
import type { JsonData } from './Json'
import { DisplayChoices, asTitle } from '../display'
import { ClassName, useClassNameGenerator } from '../hooks/className'
import { PageEvent, GoToEvent } from '../hooks/event'
import { ClassName, useClassName } from '../hooks/className'
import { AnyEvent } from '../hooks/events'
import { DisplayComp } from './display'
import { LinkRender } from './link'
@@ -13,7 +13,7 @@ interface ColumnProps {
field: string
display?: DisplayChoices
title?: string
onClick?: PageEvent | GoToEvent
onClick?: AnyEvent
className?: ClassName
}
@@ -27,10 +27,10 @@ export interface TableProps {
}
export const TableComp: FC<TableProps> = (props) => {
const { className, columns, data } = props
const { columns, data } = props
return (
<table className={useClassNameGenerator(className, props)}>
<table className={useClassName(props)}>
<thead>
<tr>
{columns.map((col, id) => (
@@ -60,7 +60,7 @@ interface CellProps {
const Cell: FC<CellProps> = ({ row, column }) => {
const { field, display, onClick } = column
const value = row[field]
let event: PageEvent | GoToEvent | null = onClick ? { ...onClick } : null
let event: AnyEvent | null = onClick ? { ...onClick } : null
if (event) {
if (event.type === 'go-to') {
// for go-to events, substitute the row values into the url

View File

@@ -1,28 +1,58 @@
import { createContext, useContext } from 'react'
import type { FastProps } from '../components'
import type { FastClassNameProps } from '../components'
import { LocationContext } from './locationContext'
export type ClassName = string | ClassName[] | Record<string, boolean | null> | undefined
export type ClassNameGenerator = (props: FastProps) => ClassName
interface ClassNameGeneratorArgs {
props: FastClassNameProps
fullPath: string
subElement?: string
}
export type ClassNameGenerator = (args: ClassNameGeneratorArgs) => ClassName
export const ClassNameContext = createContext<ClassNameGenerator | null>(null)
interface UseClassNameExtra {
// default className to use if the class name generator is not set or returns undefined.
dft?: ClassName
// identifier of the element within the component to generate the class name for.
el?: string
}
/**
* Generates a `className` from a component.
* Generates a `className` from `props`, `classNameGenerator` or the default value.
*
* @param classNameProp The `className` taken from the props sent from the backend.
* @param props The full props object sent from the backend, this is passed to the class name generator.
* @param dft default className to use if the class name generator is not set or returns undefined.
* @param extra dft class name or sub-element
*/
export function useClassNameGenerator(classNameProp: ClassName, props: FastProps, dft?: ClassName): string {
export function useClassName(props: FastClassNameProps, extra?: UseClassNameExtra): string | undefined {
const classNameGenerator = useContext(ClassNameContext)
if (combineClassNameProp(classNameProp)) {
if (!dft && classNameGenerator) {
dft = classNameGenerator(props)
const { fullPath } = useContext(LocationContext)
let { dft, el } = extra || {}
const genArgs: ClassNameGeneratorArgs = { props, fullPath, subElement: el }
if (el) {
// if getting the class for a sub-element, we don't care about `props.ClassName`
if (classNameGenerator) {
const generated = classNameGenerator(genArgs)
if (generated) {
return renderClassName(classNameGenerator(genArgs))
}
}
return combine(dft, classNameProp)
return renderClassName(dft)
} else {
return renderClassName(classNameProp)
const { className } = props
if (combineClassNameProp(className)) {
if (classNameGenerator) {
dft = classNameGenerator(genArgs) || dft
}
return combine(dft, className)
} else {
return renderClassName(className)
}
}
}

View File

@@ -12,14 +12,20 @@ export interface GoToEvent {
url: string
}
export interface BackEvent {
type: 'back'
}
export type AnyEvent = PageEvent | GoToEvent | BackEvent
function pageEventType(event: PageEvent): string {
return `fastui:${event.name}`
}
export function useFireEvent(): { fireEvent: (event?: PageEvent | GoToEvent) => void } {
export function useFireEvent(): { fireEvent: (event?: AnyEvent) => void } {
const location = useContext(LocationContext)
function fireEvent(event?: PageEvent | GoToEvent) {
function fireEvent(event?: AnyEvent) {
if (!event) {
return
}
@@ -32,6 +38,9 @@ export function useFireEvent(): { fireEvent: (event?: PageEvent | GoToEvent) =>
case 'go-to':
location.goto(event.url)
break
case 'back':
location.back()
break
}
}

View File

@@ -11,6 +11,7 @@ function parseLocation(): string {
export interface LocationState {
fullPath: string
goto: (pushPath: string) => void
back: () => void
}
const initialPath = parseLocation()
@@ -18,6 +19,7 @@ const initialPath = parseLocation()
const initialState = {
fullPath: initialPath,
goto: () => null,
back: () => null,
}
export const LocationContext = createContext<LocationState>(initialState)
@@ -68,7 +70,27 @@ export function LocationProvider({ children }: { children: ReactNode }) {
},
[setError],
),
back: useCallback(() => {
window.history.back()
}, []),
}
return <LocationContext.Provider value={value}>{children}</LocationContext.Provider>
}
export function pathMatch(matchPath: string | boolean | undefined, fullPath: string): boolean {
if (typeof matchPath === 'string') {
if (matchPath.startsWith('regex:')) {
const regex = new RegExp(matchPath.slice(6))
return regex.test(fullPath)
} else if (matchPath.startsWith('startswith:')) {
return fullPath.startsWith(matchPath.slice(12))
} else {
return fullPath === matchPath
}
} else if (matchPath === undefined) {
return false
} else {
return matchPath
}
}

View File

@@ -1,15 +1,21 @@
import { FC } from 'react'
import type { ErrorDisplayType } from './hooks/error'
import { LocationProvider } from './hooks/locationContext'
import { FastUIController } from './controller'
import { ClassNameContext, ClassNameGenerator } from './hooks/className'
import { ErrorContextProvider, ErrorDisplayType } from './hooks/error'
import { ErrorContextProvider } from './hooks/error'
import { ConfigContext } from './hooks/config'
import { FastProps } from './components'
import { DisplayChoices } from './display'
import { DevReloadProvider } from './hooks/dev'
export type { ClassNameGenerator, ErrorDisplayType, FastProps, DisplayChoices }
export * as components from './components'
export * as events from './hooks/events'
export type { DisplayChoices } from './display'
export type { ClassName, ClassNameGenerator } from './hooks/className'
export { useClassName, renderClassName } from './hooks/className'
export { pathMatch } from './hooks/locationContext'
export type CustomRender = (props: FastProps) => FC | void

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FastUI</title>
<title></title>
</head>
<body>
<div id="root"></div>

View File

@@ -2,7 +2,7 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import 'fastui-bootstrap/main.scss'
import './main.scss'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>

View File

@@ -9,38 +9,85 @@ from fastapi import UploadFile
from fastui import AnyComponent, FastUI, dev_fastapi_app
from fastui import components as c
from fastui.display import Display
from fastui.events import GoToEvent, PageEvent
from fastui.events import BackEvent, GoToEvent, PageEvent
from fastui.forms import FormFile, FormResponse, fastui_form
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, SecretStr, field_validator
from pydantic_core import PydanticCustomError
# app = FastAPI()
app = dev_fastapi_app()
@app.get('/api/', response_model=FastUI, response_model_exclude_none=True)
def read_root() -> AnyComponent:
return c.Page(
children=[
c.Heading(text='Hello World'),
c.Row(
children=[
c.Col(children=[c.Text(text='Hello World')]),
c.Col(children=[c.Button(text='Show Modal', on_click=PageEvent(name='modal'))]),
c.Col(children=[c.Button(text='View Table', on_click=GoToEvent(url='/table'))]),
c.Col(children=[c.Button(text='Form', on_click=GoToEvent(url='/form'))]),
]
),
c.Modal(
title='Modal Title',
body=[c.ServerLoad(url='/modal')],
footer=[c.Button(text='Close', on_click=PageEvent(name='modal'))],
open_trigger=PageEvent(name='modal'),
),
def navbar() -> AnyComponent:
return c.Navbar(
title='FastUI Demo',
links=[
c.Link(components=[c.Text(text='Home')], on_click=GoToEvent(url='/'), active='/'),
c.Link(components=[c.Text(text='Table')], on_click=GoToEvent(url='/table'), active='/table'),
c.Link(components=[c.Text(text='Forms')], on_click=GoToEvent(url='/form'), active='/form'),
],
class_name='+ mt-4',
)
def panel(*components: AnyComponent) -> AnyComponent:
return c.Div(class_name='col border rounded m-1 p-2 pb-3', components=list(components))
@app.get('/api/', response_model=FastUI, response_model_exclude_none=True)
def read_root() -> list[AnyComponent]:
return [
c.PageTitle(text='FastUI Demo'),
navbar(),
c.Page(
components=[
c.Heading(text='Modal and Flex examples'),
c.Paragraph(text='Below is an example of a flex container with 3 panels.'),
c.Div(
class_name='row',
components=[
panel(
c.Heading(text='Panel 1', level=3),
c.Paragraph(text='This is a div with a border and rounded corners.'),
),
panel(
c.Heading(text='Panel 2', level=3),
c.Paragraph(text='Click the link below to open a modal with content included directly'),
c.Button(text='Show Static Modal', on_click=PageEvent(name='static-modal')),
),
panel(
c.Heading(text='Panel 3', level=3),
c.Paragraph(
text=(
'Click the link below to open a modal with content loaded from the '
'server when the modal is opened.'
)
),
c.Button(text='Show Dynamic Modal', on_click=PageEvent(name='dynamic-modal')),
),
],
),
c.Modal(
title='Static Modal',
body=[c.Paragraph(text='This is some static content in a modal.')],
footer=[
c.Button(text='Close', on_click=PageEvent(name='static-modal')),
],
open_trigger=PageEvent(name='static-modal'),
),
c.Modal(
title='Dynamic Modal',
body=[c.ServerLoad(url='/modal')],
footer=[
c.Button(text='Close', on_click=PageEvent(name='dynamic-modal')),
],
open_trigger=PageEvent(name='dynamic-modal'),
),
],
class_name='+ mt-4',
),
]
class MyTableRow(BaseModel):
id: int = Field(title='ID')
name: str = Field(title='Name')
@@ -49,30 +96,34 @@ class MyTableRow(BaseModel):
@app.get('/api/modal', response_model=FastUI, response_model_exclude_none=True)
async def modal_view() -> AnyComponent:
await asyncio.sleep(2)
return c.Text(text='Modal Content Dynamic')
async def modal_view() -> list[AnyComponent]:
await asyncio.sleep(0.5)
return [c.Text(text='Modal Content Dynamic')]
@app.get('/api/table', response_model=FastUI, response_model_exclude_none=True)
def table_view() -> AnyComponent:
return c.Page(
children=[
c.Heading(text='Table'),
c.Table[MyTableRow](
data=[
MyTableRow(id=1, name='John', dob=date(1990, 1, 1), enabled=True),
MyTableRow(id=2, name='Jane', dob=date(1991, 1, 1), enabled=False),
MyTableRow(id=3, name='Jack', dob=date(1992, 1, 1)),
],
columns=[
c.TableColumn(field='name', on_click=GoToEvent(url='/more/{id}/')),
c.TableColumn(field='dob', display=Display.date),
c.TableColumn(field='enabled'),
],
),
]
)
def table_view() -> list[AnyComponent]:
return [
navbar(),
c.PageTitle(text='FastUI Demo - Table'),
c.Page(
components=[
c.Heading(text='Table'),
c.Table[MyTableRow](
data=[
MyTableRow(id=1, name='John', dob=date(1990, 1, 1), enabled=True),
MyTableRow(id=2, name='Jane', dob=date(1991, 1, 1), enabled=False),
MyTableRow(id=3, name='Jack', dob=date(1992, 1, 1)),
],
columns=[
c.TableColumn(field='name', on_click=GoToEvent(url='/more/{id}/')),
c.TableColumn(field='dob', display=Display.date),
c.TableColumn(field='enabled'),
],
),
]
),
]
class NestedFormModel(BaseModel):
@@ -89,7 +140,7 @@ class ToolEnum(StrEnum):
class MyFormModel(BaseModel):
name: str = Field(default='foobar', title='Name')
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
profile_pic: Annotated[UploadFile, FormFile(accept='image/*', max_size=16_000)]
@@ -101,23 +152,35 @@ class MyFormModel(BaseModel):
# size: PositiveInt = None
# enabled: bool = False
# nested: NestedFormModel
password: SecretStr
@field_validator('name')
def name_validator(cls, v: str) -> str:
if v[0].islower():
raise PydanticCustomError('lower', 'Name must start with a capital letter')
return v
@app.get('/api/form', response_model=FastUI, response_model_exclude_none=True)
def form_view() -> AnyComponent:
return c.Page(
children=[
c.Heading(text='Form'),
c.ModelForm[MyFormModel](
submit_url='/api/form',
success_event=PageEvent(name='form_success'),
# footer=[
# c.Button(text='Cancel', on_click=GoToEvent(url='/')),
# c.Button(text='Submit', html_type='submit'),
# ]
),
]
)
def form_view() -> list[AnyComponent]:
return [
navbar(),
c.PageTitle(text='FastUI Demo - Form Examples'),
c.Page(
components=[
c.Heading(text='Form'),
c.Link(components=[c.Text(text='Back')], on_click=BackEvent()),
c.ModelForm[MyFormModel](
submit_url='/api/form',
success_event=PageEvent(name='form_success'),
# footer=[
# c.Button(text='Cancel', on_click=GoToEvent(url='/')),
# c.Button(text='Submit', html_type='submit'),
# ]
),
]
),
]
@app.post('/api/form')

View File

@@ -10,4 +10,4 @@ __all__ = 'AnyComponent', 'FastUI', 'dev_fastapi_app', 'Display'
class FastUI(pydantic.RootModel):
root: AnyComponent
root: list[AnyComponent]

View File

@@ -36,77 +36,118 @@ __all__ = (
)
class Text(pydantic.BaseModel):
class Text(pydantic.BaseModel, extra='forbid'):
text: str
type: typing.Literal['Text'] = 'Text'
class Div(pydantic.BaseModel):
children: list[AnyComponent]
class_name: extra.ClassName | None = None
class Paragraph(pydantic.BaseModel, extra='forbid'):
text: str
type: typing.Literal['Paragraph'] = 'Paragraph'
class PageTitle(pydantic.BaseModel, extra='forbid'):
"""
This sets the title of the HTML page via the `document.title` property.
"""
text: str
type: typing.Literal['PageTitle'] = 'PageTitle'
class Div(pydantic.BaseModel, extra='forbid'):
components: list[AnyComponent]
class_name: extra.ClassName = None
type: typing.Literal['Div'] = 'Div'
class Page(pydantic.BaseModel):
class Page(pydantic.BaseModel, extra='forbid'):
"""
Similar to `container` in many UI frameworks, this should be a reasonable root component for most pages.
"""
children: list[AnyComponent]
class_name: extra.ClassName | None = None
components: list[AnyComponent]
class_name: extra.ClassName = None
type: typing.Literal['Page'] = 'Page'
class Heading(pydantic.BaseModel):
class Heading(pydantic.BaseModel, extra='forbid'):
text: str
level: typing.Literal[1, 2, 3, 4, 5, 6] = 1
class_name: extra.ClassName | None = None
class_name: extra.ClassName = None
type: typing.Literal['Heading'] = 'Heading'
class Row(pydantic.BaseModel):
children: list[AnyComponent]
class_name: extra.ClassName | None = None
type: typing.Literal['Row'] = 'Row'
class Col(pydantic.BaseModel):
children: list[AnyComponent]
class_name: extra.ClassName | None = None
type: typing.Literal['Col'] = 'Col'
class Button(pydantic.BaseModel):
class Button(pydantic.BaseModel, extra='forbid'):
text: str
on_click: events.Event | None = pydantic.Field(default=None, serialization_alias='onClick')
on_click: events.AnyEvent | None = pydantic.Field(default=None, serialization_alias='onClick')
html_type: typing.Literal['button', 'submit', 'reset'] | None = pydantic.Field(
default=None, serialization_alias='htmlType'
)
class_name: extra.ClassName | None = None
class_name: extra.ClassName = None
type: typing.Literal['Button'] = 'Button'
class Modal(pydantic.BaseModel):
class Link(pydantic.BaseModel, extra='forbid'):
components: list[AnyComponent]
on_click: events.AnyEvent | None = pydantic.Field(default=None, serialization_alias='onClick')
mode: typing.Literal['navbar', 'tabs', 'vertical'] | None = None
active: bool | str | None = None
class_name: extra.ClassName = None
type: typing.Literal['Link'] = 'Link'
class LinkList(pydantic.BaseModel, extra='forbid'):
links: list[Link]
mode: typing.Literal['tabs', 'vertical'] | None = None
class_name: extra.ClassName = None
type: typing.Literal['LinkList'] = 'LinkList'
class Navbar(pydantic.BaseModel, extra='forbid'):
title: str | None = None
title_event: events.AnyEvent | None = pydantic.Field(default=None, serialization_alias='titleEvent')
links: list[Link] = pydantic.Field(default_factory=list)
class_name: extra.ClassName = None
type: typing.Literal['Navbar'] = 'Navbar'
class Modal(pydantic.BaseModel, extra='forbid'):
title: str
body: list[AnyComponent]
footer: list[AnyComponent] | None = None
open_trigger: events.PageEvent | None = pydantic.Field(default=None, serialization_alias='openTrigger')
open: bool = False
class_name: extra.ClassName | None = None
class_name: extra.ClassName = None
type: typing.Literal['Modal'] = 'Modal'
class ServerLoad(pydantic.BaseModel):
class ServerLoad(pydantic.BaseModel, extra='forbid'):
"""
A component that will be replaced by the server with the component returned by the given URL.
"""
url: str
class_name: extra.ClassName | None = None
class_name: extra.ClassName = None
type: typing.Literal['ServerLoad'] = 'ServerLoad'
AnyComponent = typing.Annotated[
Text | Div | Page | Heading | Row | Col | Button | Modal | ServerLoad | Table | Form | ModelForm | FormField,
Text
| Paragraph
| PageTitle
| Div
| Page
| Heading
| Button
| Link
| LinkList
| Navbar
| Modal
| ServerLoad
| Table
| Form
| ModelForm
| FormField,
pydantic.Field(discriminator='type'),
]

View File

@@ -2,4 +2,4 @@ from typing import Annotated
from pydantic import Field
ClassName = Annotated[str | list[str] | dict[str, bool | None], Field(serialization_alias='className')]
ClassName = Annotated[str | list[str] | dict[str, bool | None] | None, Field(serialization_alias='className')]

View File

@@ -10,7 +10,7 @@ from . import extra
if typing.TYPE_CHECKING:
from . import AnyComponent
InputHtmlType = typing.Literal['text', 'date', 'datetime-local', 'time', 'email', 'url', 'file', 'number']
InputHtmlType = typing.Literal['text', 'date', 'datetime-local', 'time', 'email', 'url', 'number', 'password']
class BaseFormField(pydantic.BaseModel, ABC, defer_build=True):
@@ -19,12 +19,14 @@ class BaseFormField(pydantic.BaseModel, ABC, defer_build=True):
required: bool = False
error: str | None = None
locked: bool = False
description: str | None = None
class_name: extra.ClassName | None = None
class FormFieldInput(BaseFormField):
html_type: InputHtmlType = pydantic.Field(default='text', serialization_alias='htmlType')
initial: str | int | float | None = None
placeholder: str | None = None
type: typing.Literal['FormFieldInput'] = 'FormFieldInput'

View File

@@ -20,7 +20,7 @@ class TableColumn(pydantic.BaseModel):
field: str
display: Display | None = None
title: str | None = None
on_click: typing.Annotated[events.Event | None, pydantic.Field(serialization_alias='onClick')] = None
on_click: typing.Annotated[events.AnyEvent | None, pydantic.Field(serialization_alias='onClick')] = None
class_name: extra.ClassName | None = None

View File

@@ -14,4 +14,8 @@ class GoToEvent(BaseModel):
type: Literal['go-to'] = 'go-to'
Event = Annotated[PageEvent | GoToEvent, Field(discriminator='type')]
class BackEvent(BaseModel):
type: Literal['back'] = 'back'
AnyEvent = Annotated[PageEvent | GoToEvent | BackEvent, Field(discriminator='type')]

View File

@@ -132,7 +132,7 @@ class FormFile:
class FormResponse(pydantic.BaseModel):
event: events.Event
event: events.AnyEvent
type: typing.Literal['FormResponse'] = 'FormResponse'

View File

@@ -164,6 +164,7 @@ def json_schema_field_to_field(
title=title,
required=required,
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', {})
@@ -173,6 +174,7 @@ def json_schema_field_to_field(
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(
@@ -181,6 +183,7 @@ def json_schema_field_to_field(
required=required,
multiple=schema.get('multiple', False),
accept=schema.get('accept'),
description=schema.get('description'),
)
else:
return FormFieldInput(
@@ -189,6 +192,7 @@ def json_schema_field_to_field(
html_type=input_html_type(schema),
required=required,
initial=schema.get('default'),
description=schema.get('description'),
)
@@ -261,6 +265,7 @@ type_lookup: dict[str, InputHtmlType] = {
'string-email': 'email',
'string-uri': 'url',
'string-uuid': 'text',
'string-password': 'password',
'number': 'number',
'integer': 'number',
}