diff --git a/.darklua-wally.json b/.darklua-wally.json new file mode 100644 index 0000000..f45513f --- /dev/null +++ b/.darklua-wally.json @@ -0,0 +1,18 @@ +{ + "process": [ + { + "rule": "convert_require", + "current": { + "name": "path", + "sources": { + "@pkg": "." + } + }, + "target": { + "name": "roblox", + "rojo_sourcemap": "./sourcemap.json", + "indexing_style": "find_first_child" + } + } + ] +} diff --git a/.darklua.json b/.darklua.json new file mode 100644 index 0000000..136ba9f --- /dev/null +++ b/.darklua.json @@ -0,0 +1,18 @@ +{ + "rules": [ + { + "rule": "convert_require", + "current": { + "name": "path", + "sources": { + "@pkg": "node_modules/.luau-aliases" + } + }, + "target": { + "name": "roblox", + "rojo_sourcemap": "./darklua-sourcemap.json", + "indexing_style": "find_first_child" + } + } + ] +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..4bcc987 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +* text eol=lf +*.luau linguist-language=Lua + +*.gif binary +*.ico binary +*.jpg binary +*.png binary diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..088544c --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,5 @@ +Closes #[issue number] + + + +- [ ] add entry to the changelog diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..cfa7247 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,148 @@ +name: Release + +"on": + workflow_dispatch: + inputs: + release_tag: + description: The version to release starting with `v` + required: true + type: string + release_ref: + description: The branch, tag or SHA to checkout (default to latest) + default: "" + type: string + +permissions: + contents: write + +jobs: + publish-package: + name: Publish package + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Enable corepack + run: corepack enable + + - uses: actions/setup-node@v3 + with: + node-version: latest + registry-url: https://registry.npmjs.org + cache: npm + cache-dependency-path: package-lock.json + + - name: Install packages + run: npm ci + + - name: Publish to npm + run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + publish-wally-package: + needs: publish-package + name: Publish wally package + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Enable corepack + run: corepack enable + + - uses: Roblox/setup-foreman@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/setup-node@v3 + with: + node-version: latest + registry-url: https://registry.npmjs.org + cache: npm + cache-dependency-path: package-lock.json + + - name: Install packages + run: npm ci + + - name: Build assets + run: npm run build-assets + + - name: Login to wally + run: wally login --project-path build/wally --token ${{ secrets.WALLY_ACCESS_TOKEN }} + + - name: Publish to wally + run: wally publish --project-path build/wally + + create-release: + needs: publish-package + name: Create release + runs-on: ubuntu-latest + outputs: + upload_url: ${{ steps.create_release.outputs.upload_url }} + steps: + - uses: actions/checkout@v4 + + - name: Create tag + run: | + git fetch --tags --no-recurse-submodules + if [ ! $(git tag -l ${{ inputs.release_tag }}) ]; then + git tag ${{ inputs.release_tag }} + git push origin ${{ inputs.release_tag }} + fi + + - name: Create release + id: create_release + uses: softprops/action-gh-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ inputs.release_tag }} + name: ${{ inputs.release_tag }} + draft: false + + build-assets: + needs: create-release + name: Add assets + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - artifact-name: studiocomponents.rbxm + path: build/studiocomponents.rbxm + asset-type: application/octet-stream + steps: + - uses: actions/checkout@v4 + + - uses: Roblox/setup-foreman@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/setup-node@v3 + with: + node-version: latest + registry-url: https://registry.npmjs.org + cache: npm + cache-dependency-path: package-lock.json + + - name: Install packages + run: npm ci + + - name: Build assets + run: npm run build-assets + + - name: Upload asset + uses: actions/upload-artifact@v3 + with: + name: ${{ matrix.artifact-name }} + path: ${{ matrix.path }} + + - name: Add asset to Release + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ needs.create-release.outputs.upload_url }} + asset_path: ${{ matrix.path }} + asset_name: ${{ matrix.artifact-name }} + asset_content_type: ${{ matrix.asset-type }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e49bed4 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,40 @@ +name: Tests + +"on": + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + test: + name: Run tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: Roblox/setup-foreman@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/setup-node@v3 + with: + node-version: latest + registry-url: https://registry.npmjs.org + cache: npm + cache-dependency-path: package-lock.json + + - name: Install packages + run: npm ci + + - name: Run linter + run: npm run lint:selene + # skip luau-lsp as it cannot ignore errors in node_modules + + - name: Verify code style + run: npm run style-check + + - name: Build assets + run: npm run build-assets diff --git a/.gitignore b/.gitignore index a940b9a..dd0b073 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,23 @@ -# Roblox -/Packages +/site +/assets + +/*.rbxl +/*.rbxlx +/*.rbxl.lock +/*.rbxlx.lock /*.rbxm +/*.rbxmx -# Dev -/.vscode -/roblox.toml -sourcemap.json +/build +/serve +/temp +/NOTES.txt -# Docs -/assets -/out -/site -/archived \ No newline at end of file +/node_modules + +.yarn + +/globalTypes.d.lua + +**/sourcemap.json +**/darklua-sourcemap.json diff --git a/.luau-analyze.json b/.luau-analyze.json new file mode 100644 index 0000000..18c90fd --- /dev/null +++ b/.luau-analyze.json @@ -0,0 +1,6 @@ +{ + "luau-lsp.require.mode": "relativeToFile", + "luau-lsp.require.directoryAliases": { + "@pkg": "node_modules/.luau-aliases" + } +} diff --git a/.luaurc b/.luaurc new file mode 100644 index 0000000..9a3da68 --- /dev/null +++ b/.luaurc @@ -0,0 +1,10 @@ +{ + "languageMode": "strict", + "lintErrors": true, + "lint": { + "*": true + }, + "aliases": { + "pkg": "./node_modules/.luau-aliases" + } +} diff --git a/.moonwave/custom.css b/.moonwave/custom.css new file mode 100644 index 0000000..418ea09 --- /dev/null +++ b/.moonwave/custom.css @@ -0,0 +1,16 @@ +td:nth-child(1) { + background-color: rgb(46, 46, 46); +} + +td:nth-child(2) { + background-color: rgb(255, 255, 255) +} + +td { + width: auto; +} + +td.min { + width: 1%; + white-space: nowrap; +} \ No newline at end of file diff --git a/.moonwave/static/components/background/dark.png b/.moonwave/static/components/background/dark.png new file mode 100644 index 0000000..5d34add Binary files /dev/null and b/.moonwave/static/components/background/dark.png differ diff --git a/.moonwave/static/components/background/light.png b/.moonwave/static/components/background/light.png new file mode 100644 index 0000000..da38f92 Binary files /dev/null and b/.moonwave/static/components/background/light.png differ diff --git a/.moonwave/static/components/button/dark.png b/.moonwave/static/components/button/dark.png new file mode 100644 index 0000000..63d1c91 Binary files /dev/null and b/.moonwave/static/components/button/dark.png differ diff --git a/.moonwave/static/components/button/light.png b/.moonwave/static/components/button/light.png new file mode 100644 index 0000000..66fe84c Binary files /dev/null and b/.moonwave/static/components/button/light.png differ diff --git a/.moonwave/static/components/checkbox/dark.png b/.moonwave/static/components/checkbox/dark.png new file mode 100644 index 0000000..9e30cf4 Binary files /dev/null and b/.moonwave/static/components/checkbox/dark.png differ diff --git a/.moonwave/static/components/checkbox/light.png b/.moonwave/static/components/checkbox/light.png new file mode 100644 index 0000000..e24d98a Binary files /dev/null and b/.moonwave/static/components/checkbox/light.png differ diff --git a/.moonwave/static/components/colorpicker/dark.png b/.moonwave/static/components/colorpicker/dark.png new file mode 100644 index 0000000..869ba75 Binary files /dev/null and b/.moonwave/static/components/colorpicker/dark.png differ diff --git a/.moonwave/static/components/colorpicker/light.png b/.moonwave/static/components/colorpicker/light.png new file mode 100644 index 0000000..caad24d Binary files /dev/null and b/.moonwave/static/components/colorpicker/light.png differ diff --git a/.moonwave/static/components/dropdown/dark.png b/.moonwave/static/components/dropdown/dark.png new file mode 100644 index 0000000..608cd35 Binary files /dev/null and b/.moonwave/static/components/dropdown/dark.png differ diff --git a/.moonwave/static/components/dropdown/light.png b/.moonwave/static/components/dropdown/light.png new file mode 100644 index 0000000..dd32d18 Binary files /dev/null and b/.moonwave/static/components/dropdown/light.png differ diff --git a/.moonwave/static/components/dropshadowframe/dark.png b/.moonwave/static/components/dropshadowframe/dark.png new file mode 100644 index 0000000..b057ec2 Binary files /dev/null and b/.moonwave/static/components/dropshadowframe/dark.png differ diff --git a/.moonwave/static/components/dropshadowframe/light.png b/.moonwave/static/components/dropshadowframe/light.png new file mode 100644 index 0000000..573cd0b Binary files /dev/null and b/.moonwave/static/components/dropshadowframe/light.png differ diff --git a/.moonwave/static/components/label/dark.png b/.moonwave/static/components/label/dark.png new file mode 100644 index 0000000..71e1573 Binary files /dev/null and b/.moonwave/static/components/label/dark.png differ diff --git a/.moonwave/static/components/label/light.png b/.moonwave/static/components/label/light.png new file mode 100644 index 0000000..10881e6 Binary files /dev/null and b/.moonwave/static/components/label/light.png differ diff --git a/.moonwave/static/components/loadingdots/dark.gif b/.moonwave/static/components/loadingdots/dark.gif new file mode 100644 index 0000000..8789446 Binary files /dev/null and b/.moonwave/static/components/loadingdots/dark.gif differ diff --git a/.moonwave/static/components/loadingdots/light.gif b/.moonwave/static/components/loadingdots/light.gif new file mode 100644 index 0000000..89e4b24 Binary files /dev/null and b/.moonwave/static/components/loadingdots/light.gif differ diff --git a/.moonwave/static/components/mainbutton/dark.png b/.moonwave/static/components/mainbutton/dark.png new file mode 100644 index 0000000..7e62a0b Binary files /dev/null and b/.moonwave/static/components/mainbutton/dark.png differ diff --git a/.moonwave/static/components/mainbutton/light.png b/.moonwave/static/components/mainbutton/light.png new file mode 100644 index 0000000..1998c73 Binary files /dev/null and b/.moonwave/static/components/mainbutton/light.png differ diff --git a/.moonwave/static/components/numbersequencepicker/dark.png b/.moonwave/static/components/numbersequencepicker/dark.png new file mode 100644 index 0000000..1396b1d Binary files /dev/null and b/.moonwave/static/components/numbersequencepicker/dark.png differ diff --git a/.moonwave/static/components/numbersequencepicker/light.png b/.moonwave/static/components/numbersequencepicker/light.png new file mode 100644 index 0000000..6dad932 Binary files /dev/null and b/.moonwave/static/components/numbersequencepicker/light.png differ diff --git a/.moonwave/static/components/numericinput/dark.png b/.moonwave/static/components/numericinput/dark.png new file mode 100644 index 0000000..c8d05d2 Binary files /dev/null and b/.moonwave/static/components/numericinput/dark.png differ diff --git a/.moonwave/static/components/numericinput/light.png b/.moonwave/static/components/numericinput/light.png new file mode 100644 index 0000000..de1af41 Binary files /dev/null and b/.moonwave/static/components/numericinput/light.png differ diff --git a/.moonwave/static/components/progressbar/dark.png b/.moonwave/static/components/progressbar/dark.png new file mode 100644 index 0000000..71e62e9 Binary files /dev/null and b/.moonwave/static/components/progressbar/dark.png differ diff --git a/.moonwave/static/components/progressbar/light.png b/.moonwave/static/components/progressbar/light.png new file mode 100644 index 0000000..0bac5ce Binary files /dev/null and b/.moonwave/static/components/progressbar/light.png differ diff --git a/.moonwave/static/components/radiobutton/dark.png b/.moonwave/static/components/radiobutton/dark.png new file mode 100644 index 0000000..8c68d71 Binary files /dev/null and b/.moonwave/static/components/radiobutton/dark.png differ diff --git a/.moonwave/static/components/radiobutton/light.png b/.moonwave/static/components/radiobutton/light.png new file mode 100644 index 0000000..c6d6c3c Binary files /dev/null and b/.moonwave/static/components/radiobutton/light.png differ diff --git a/.moonwave/static/components/scrollframe/dark.png b/.moonwave/static/components/scrollframe/dark.png new file mode 100644 index 0000000..fd27b5c Binary files /dev/null and b/.moonwave/static/components/scrollframe/dark.png differ diff --git a/.moonwave/static/components/scrollframe/light.png b/.moonwave/static/components/scrollframe/light.png new file mode 100644 index 0000000..3b78365 Binary files /dev/null and b/.moonwave/static/components/scrollframe/light.png differ diff --git a/.moonwave/static/components/slider/dark.png b/.moonwave/static/components/slider/dark.png new file mode 100644 index 0000000..95248a8 Binary files /dev/null and b/.moonwave/static/components/slider/dark.png differ diff --git a/.moonwave/static/components/slider/light.png b/.moonwave/static/components/slider/light.png new file mode 100644 index 0000000..7eacc59 Binary files /dev/null and b/.moonwave/static/components/slider/light.png differ diff --git a/.moonwave/static/components/splitter/dark.png b/.moonwave/static/components/splitter/dark.png new file mode 100644 index 0000000..7d8eb21 Binary files /dev/null and b/.moonwave/static/components/splitter/dark.png differ diff --git a/.moonwave/static/components/splitter/light.png b/.moonwave/static/components/splitter/light.png new file mode 100644 index 0000000..9a70e0e Binary files /dev/null and b/.moonwave/static/components/splitter/light.png differ diff --git a/.moonwave/static/components/tabcontainer/dark.png b/.moonwave/static/components/tabcontainer/dark.png new file mode 100644 index 0000000..26297b6 Binary files /dev/null and b/.moonwave/static/components/tabcontainer/dark.png differ diff --git a/.moonwave/static/components/tabcontainer/light.png b/.moonwave/static/components/tabcontainer/light.png new file mode 100644 index 0000000..453bc5d Binary files /dev/null and b/.moonwave/static/components/tabcontainer/light.png differ diff --git a/.moonwave/static/components/textinput/dark.png b/.moonwave/static/components/textinput/dark.png new file mode 100644 index 0000000..67c020f Binary files /dev/null and b/.moonwave/static/components/textinput/dark.png differ diff --git a/.moonwave/static/components/textinput/light.png b/.moonwave/static/components/textinput/light.png new file mode 100644 index 0000000..59b1fa1 Binary files /dev/null and b/.moonwave/static/components/textinput/light.png differ diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..8ad5e2f --- /dev/null +++ b/.npmignore @@ -0,0 +1,27 @@ +/.* +/scripts +/assets +/docs +/site + +/build +/serve +/temp + +/*.json +/*.json5 +/*.yml +/*.toml +/*.md +/*.txt +/*.tgz + +*.d.lua +*.d.luau + +**/*.rbxl +**/*.rbxlx +**/*.rbxl.lock +**/*.rbxlx.lock +**/*.rbxm +**/*.rbxmx diff --git a/.styluaignore b/.styluaignore new file mode 100644 index 0000000..98c2f05 --- /dev/null +++ b/.styluaignore @@ -0,0 +1,7 @@ +/node_modules +/temp +/build +/serve + +**/*.d.lua +**/*.d.luau diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..f639360 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "johnnymorganz.luau-lsp", + "johnnymorganz.stylua", + "kampfkarren.selene-vscode" + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..20bf943 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "luau-lsp.require.directoryAliases": { + "@pkg": "node_modules/.luau-aliases" + }, + "luau-lsp.require.mode": "relativeToFile", + "luau-lsp.completion.imports.requireStyle": "alwaysRelative", + "luau-lsp.types.roblox": true, + "luau-lsp.sourcemap.rojoProjectFile": "model.project.json", +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6eb541d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,26 @@ +# Changelog + +## 1.0.0 + +Migrated from [Roact](https://github.com/Roblox/roact) to [react-lua](https://github.com/jsdotlua/react-lua) +and rewrote the library from the ground up. + +There are many API differences; consult the docs on this. Removal of some components was either due +to no longer being in scope for this project or requiring an API redesign which didn't make it +into v1.0.0. + +### Added + +- Full type annotations +- Components: DropShadowFrame, LoadingDots, NumberSequencePicker, NumericInput, ProgressBar +- Hooks: useMouseIcon + +### Removed + +- Components: BaseButton, Tooltip, VerticalCollapsibleSection, VerticalExpandingList, Widget, withTheme +- Contexts: ThemeContext +- Hooks: usePlugin + +## 0.1.0 - 0.1.4 + +Initial release through to the final Roact version. Added various components and changed APIs. diff --git a/LICENSE b/LICENSE index 696d8ce..1db74c3 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ -MIT License +The MIT License (MIT) -Copyright (c) 2021 sircfenner +Copyright (c) 2024 sircfenner Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -9,13 +9,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md index 524980b..6f60e2a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,42 @@ -Help and documentation can be found at: +# StudioComponents -https://sircfenner.github.io/StudioComponents/ \ No newline at end of file +## [React the documentation here!](https://sircfenner.github.io/StudioComponents/) + +A collection of React implementations of Roblox Studio components such as Checkboxes, Buttons, and Dropdowns. This is intended for building plugins for Roblox Studio. + +

+ + +

+

An example Dropdown

+ +This project is built for [react-lua](https://github.com/jsdotlua/react-lua), Roblox's translation +of upstream ReactJS 17.x into Luau. + +## Installation + +### Wally + +Add `studiocomponents` to your `wally.toml`: + +```toml +studiocomponents = "sircfenner/studiocomponents@1.0.0" +``` + +### NPM & yarn + +Add `studiocomponents` to your dependencies: + +```bash +npm install studiocomponents +``` + +```bash +yarn add studiocomponents +``` + +Run `npmluau`. + +## License + +This project is available under the MIT license. See [LICENSE](LICENSE) for details. diff --git a/aftman.toml b/aftman.toml deleted file mode 100644 index 4e8ee8a..0000000 --- a/aftman.toml +++ /dev/null @@ -1,2 +0,0 @@ -[tools] -rojo = "rojo-rbx/rojo@7.3.0" \ No newline at end of file diff --git a/default.project.json b/default.project.json deleted file mode 100644 index b3ef5cc..0000000 --- a/default.project.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "StudioComponents", - "tree": { - "$path": "src" - } -} \ No newline at end of file diff --git a/develop.project.json b/develop.project.json deleted file mode 100644 index 02cd333..0000000 --- a/develop.project.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "StudioComponentsDev", - "tree": { - "$className": "DataModel", - "ServerStorage": { - "$className": "ServerStorage", - "Packages": { - "$className": "Folder", - "$path": "Packages", - "StudioComponents": { - "$path": "src" - } - } - } - } -} \ No newline at end of file diff --git a/docs/components/background.md b/docs/components/background.md deleted file mode 100644 index 5de8859..0000000 --- a/docs/components/background.md +++ /dev/null @@ -1,28 +0,0 @@ -A solid-color borderless frame. It provides the same background color as built-in Studio widgets, for example Explorer and Properties. - -| Dark | Light | -| ---- | ----- | -| | | - -This is commonly used for containing the main contents of a plugin, for example as a child of a widget with the rest of the plugin elements as its children. - -``` -🖥️ Widget -└───🖼️ Background - └───🔠 ... - └───🔠 ... -``` - -## API & Usage - -This component renders a single frame. Any children passed to it will be rendered as children of the frame. - -### Default props - -| Property | Value | -| ----------- | ----------------------- | -| Size | `UDim2.fromScale(1, 1)` | -| Position | `UDim2.fromScale(0, 0)` | -| AnchorPoint | `Vector2.new(0, 0)` | -| LayoutOrder | 0 | -| ZIndex | 1 | diff --git a/docs/components/button.md b/docs/components/button.md deleted file mode 100644 index db535d2..0000000 --- a/docs/components/button.md +++ /dev/null @@ -1,7 +0,0 @@ -| Variant | Dark | Light | -| ------- | ---- | ----- | -| Default | ![](../img/button/dark/default.png) | ![](../img/button/light/default.png) | -| Hovered | ![](../img/button/dark/hovered.png) | ![](../img/button/light/hovered.png) | -| Pressed | ![](../img/button/dark/pressed.png) | ![](../img/button/light/pressed.png) | -| Selected | ![](../img/button/dark/selected.png) | ![](../img/button/light/selected.png) | -| Disabled | ![](../img/button/dark/disabled.png) | ![](../img/button/light/disabled.png) | \ No newline at end of file diff --git a/docs/components/checkbox.md b/docs/components/checkbox.md deleted file mode 100644 index 4560b0d..0000000 --- a/docs/components/checkbox.md +++ /dev/null @@ -1,7 +0,0 @@ -| Variant | Dark | Light | -| ------- | ---- | ----- | -| True | ![](../img/checkbox/dark/true.png) | ![](../img/checkbox/light/true.png) | -| False | ![](../img/checkbox/dark/false.png) | ![](../img/checkbox/light/false.png) | -| Indeterminate | ![](../img/checkbox/dark/indeterminate.png) | ![](../img/checkbox/light/indeterminate.png) | -| Hovered | ![](../img/checkbox/dark/hovered.png) | ![](../img/checkbox/light/hovered.png) | -| Disabled | ![](../img/checkbox/dark/disabled-true.png)
![](../img/checkbox/dark/disabled-false.png)
![](../img/checkbox/dark/disabled-indeterminate.png) | ![](../img/checkbox/light/disabled-true.png)
![](../img/checkbox/light/disabled-false.png)
![](../img/checkbox/light/disabled-indeterminate.png) | \ No newline at end of file diff --git a/docs/components/colorpicker.md b/docs/components/colorpicker.md deleted file mode 100644 index e69de29..0000000 diff --git a/docs/components/dropdown.md b/docs/components/dropdown.md deleted file mode 100644 index e69de29..0000000 diff --git a/docs/components/label.md b/docs/components/label.md deleted file mode 100644 index e69de29..0000000 diff --git a/docs/components/mainbutton.md b/docs/components/mainbutton.md deleted file mode 100644 index e69de29..0000000 diff --git a/docs/components/scrollframe.md b/docs/components/scrollframe.md deleted file mode 100644 index e69de29..0000000 diff --git a/docs/components/slider.md b/docs/components/slider.md deleted file mode 100644 index e69de29..0000000 diff --git a/docs/components/textinput.md b/docs/components/textinput.md deleted file mode 100644 index e69de29..0000000 diff --git a/docs/components/verticalcollapsiblesection.md b/docs/components/verticalcollapsiblesection.md deleted file mode 100644 index e69de29..0000000 diff --git a/docs/components/verticalexpandinglist.md b/docs/components/verticalexpandinglist.md deleted file mode 100644 index e69de29..0000000 diff --git a/docs/components/widget.md b/docs/components/widget.md deleted file mode 100644 index e69de29..0000000 diff --git a/docs/extra.css b/docs/extra.css deleted file mode 100644 index a1ec846..0000000 --- a/docs/extra.css +++ /dev/null @@ -1,38 +0,0 @@ -/* mkdocs-material/blob/master/src/assets/stylesheets/main/_typeset.scss */ - -.md-typeset img, .swatch { - box-shadow: 0 0 5px 5px rgba(0, 0, 0, 0.1); - vertical-align: middle; -} - -.md-typeset table { - --special-border: 1px dashed rgba(255, 255, 255, 0.2); -} - -.md-typeset table:not([class]) { - border: var(--special-border); - border-top: none; - border-left: none; - border-right: none; - line-height: 28px; -} - -.md-typeset table:not([class]) td { - background: rgba(0, 0, 0, 0.07); - border-top: var(--special-border); - border-left: var(--special-border); - vertical-align: middle; -} - -.md-typeset table:not([class]) td:last-child { - border-right: var(--special-border); -} - -.md-typeset table:not([class]) th { - font-weight: bold; -} - -.md-content__button { - /* edit pencil icon */ - visibility: hidden; -} \ No newline at end of file diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..5a349cd --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,44 @@ +--- +sidebar_position: 2 +--- + +# Getting Started + +This project is built for react-lua, which can be installed either via NPM/yarn, wally, or a release. See the [repository](https://github.com/jsdotlua/react-lua) for more information. + +StudioComponents exposes a table of components, hooks, and a reference to the Constants file. Minimal example of using a component from StudioComponents: + +```lua +local React = require(Packages.React) +local StudioComponents = require(Packages.StudioComponents) + +local function MyComponent() + return React.createElement(StudioComponents.Label, { + Text = "Hello, from StudioComponents!" + }) +end +``` + +## Installation + +### Wally + +Add `studiocomponents` to your `wally.toml`: + +```toml +studiocomponents = "sircfenner/studiocomponents@1.0.0" +``` + +### NPM & yarn + +Add `studiocomponents` to your dependencies: + +```bash +npm install studiocomponents +``` + +```bash +yarn add studiocomponents +``` + +Run `npmluau`. diff --git a/docs/guide/installation.md b/docs/guide/installation.md deleted file mode 100644 index 712d868..0000000 --- a/docs/guide/installation.md +++ /dev/null @@ -1,17 +0,0 @@ -## Wally package - -Add this package to your project using [Wally](https://wally.run/install). - - studiocomponents = "sircfenner/studiocomponents@0.1.1" -
- -## Build from source - -Install the dependencies using [Wally](https://wally.run/install), then build the project using [Rojo](https://github.com/rojo-rbx/rojo). - - rojo build -o release.rbxm -
- -## Model file - -Grab the latest `rbxm` file from [releases](https://github.com/sircfenner/StudioComponents/releases) then drag and drop into Roblox Studio. diff --git a/docs/guide/usage.md b/docs/guide/usage.md deleted file mode 100644 index fffe269..0000000 --- a/docs/guide/usage.md +++ /dev/null @@ -1,20 +0,0 @@ -StudioComponents must be a sibling of [Roact](https://github.com/Roblox/roact/) in the Roblox instance hierarchy, for example: -``` -📂 Plugin -└───📂 Vendor - └───📃 StudioComponents - └───📃 Roact - ... -``` - -## Notes - -### Default props -Default props are listed only where they deviate from the Roblox defaults for instance properties - -### BorderMode -Generally inset: easier to reason about total size + any outer padding - -### ColorStyle props -Some -Color3 properties are available as -ColorStyle from Enum.StudioStyleGuideColor - diff --git a/docs/img/button/dark/default.png b/docs/img/button/dark/default.png deleted file mode 100644 index d816309..0000000 Binary files a/docs/img/button/dark/default.png and /dev/null differ diff --git a/docs/img/button/dark/disabled.png b/docs/img/button/dark/disabled.png deleted file mode 100644 index b56de0e..0000000 Binary files a/docs/img/button/dark/disabled.png and /dev/null differ diff --git a/docs/img/button/dark/hovered.png b/docs/img/button/dark/hovered.png deleted file mode 100644 index 8d54fbc..0000000 Binary files a/docs/img/button/dark/hovered.png and /dev/null differ diff --git a/docs/img/button/dark/pressed.png b/docs/img/button/dark/pressed.png deleted file mode 100644 index 269d0ee..0000000 Binary files a/docs/img/button/dark/pressed.png and /dev/null differ diff --git a/docs/img/button/dark/selected.png b/docs/img/button/dark/selected.png deleted file mode 100644 index 0de0802..0000000 Binary files a/docs/img/button/dark/selected.png and /dev/null differ diff --git a/docs/img/button/light/default.png b/docs/img/button/light/default.png deleted file mode 100644 index bb8513a..0000000 Binary files a/docs/img/button/light/default.png and /dev/null differ diff --git a/docs/img/button/light/disabled.png b/docs/img/button/light/disabled.png deleted file mode 100644 index 638591b..0000000 Binary files a/docs/img/button/light/disabled.png and /dev/null differ diff --git a/docs/img/button/light/hovered.png b/docs/img/button/light/hovered.png deleted file mode 100644 index 0a218f0..0000000 Binary files a/docs/img/button/light/hovered.png and /dev/null differ diff --git a/docs/img/button/light/pressed.png b/docs/img/button/light/pressed.png deleted file mode 100644 index 3414a74..0000000 Binary files a/docs/img/button/light/pressed.png and /dev/null differ diff --git a/docs/img/button/light/selected.png b/docs/img/button/light/selected.png deleted file mode 100644 index 0a218f0..0000000 Binary files a/docs/img/button/light/selected.png and /dev/null differ diff --git a/docs/img/checkbox/dark/disabled-false.png b/docs/img/checkbox/dark/disabled-false.png deleted file mode 100644 index a1d14dd..0000000 Binary files a/docs/img/checkbox/dark/disabled-false.png and /dev/null differ diff --git a/docs/img/checkbox/dark/disabled-indeterminate.png b/docs/img/checkbox/dark/disabled-indeterminate.png deleted file mode 100644 index 6aae44a..0000000 Binary files a/docs/img/checkbox/dark/disabled-indeterminate.png and /dev/null differ diff --git a/docs/img/checkbox/dark/disabled-true.png b/docs/img/checkbox/dark/disabled-true.png deleted file mode 100644 index eaa3862..0000000 Binary files a/docs/img/checkbox/dark/disabled-true.png and /dev/null differ diff --git a/docs/img/checkbox/dark/false.png b/docs/img/checkbox/dark/false.png deleted file mode 100644 index 033a84b..0000000 Binary files a/docs/img/checkbox/dark/false.png and /dev/null differ diff --git a/docs/img/checkbox/dark/hovered.png b/docs/img/checkbox/dark/hovered.png deleted file mode 100644 index 7b5b7f2..0000000 Binary files a/docs/img/checkbox/dark/hovered.png and /dev/null differ diff --git a/docs/img/checkbox/dark/indeterminate.png b/docs/img/checkbox/dark/indeterminate.png deleted file mode 100644 index 324b48f..0000000 Binary files a/docs/img/checkbox/dark/indeterminate.png and /dev/null differ diff --git a/docs/img/checkbox/dark/true.png b/docs/img/checkbox/dark/true.png deleted file mode 100644 index 88f66f0..0000000 Binary files a/docs/img/checkbox/dark/true.png and /dev/null differ diff --git a/docs/img/checkbox/light/disabled-false.png b/docs/img/checkbox/light/disabled-false.png deleted file mode 100644 index 2507de7..0000000 Binary files a/docs/img/checkbox/light/disabled-false.png and /dev/null differ diff --git a/docs/img/checkbox/light/disabled-indeterminate.png b/docs/img/checkbox/light/disabled-indeterminate.png deleted file mode 100644 index 908c889..0000000 Binary files a/docs/img/checkbox/light/disabled-indeterminate.png and /dev/null differ diff --git a/docs/img/checkbox/light/disabled-true.png b/docs/img/checkbox/light/disabled-true.png deleted file mode 100644 index 72b660e..0000000 Binary files a/docs/img/checkbox/light/disabled-true.png and /dev/null differ diff --git a/docs/img/checkbox/light/false.png b/docs/img/checkbox/light/false.png deleted file mode 100644 index b9a9a24..0000000 Binary files a/docs/img/checkbox/light/false.png and /dev/null differ diff --git a/docs/img/checkbox/light/hovered.png b/docs/img/checkbox/light/hovered.png deleted file mode 100644 index a754dc1..0000000 Binary files a/docs/img/checkbox/light/hovered.png and /dev/null differ diff --git a/docs/img/checkbox/light/indeterminate.png b/docs/img/checkbox/light/indeterminate.png deleted file mode 100644 index 889fadf..0000000 Binary files a/docs/img/checkbox/light/indeterminate.png and /dev/null differ diff --git a/docs/img/checkbox/light/true.png b/docs/img/checkbox/light/true.png deleted file mode 100644 index c49fa7c..0000000 Binary files a/docs/img/checkbox/light/true.png and /dev/null differ diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index ca8be22..0000000 --- a/docs/index.md +++ /dev/null @@ -1,24 +0,0 @@ -StudioComponents is a collection of [Roact](https://github.com/Roblox/roact) implementations of common user interface elements found in Roblox Studio such as checkboxes, input fields, and scrollers. These closely match the look and functionality of their built-in counterparts and should be used to create user plugins. - -These components also leverage the theming API to dynamically recolor according to the user's theme setting in Studio. - -!!! warning - This project is a work in progress - expect breaking changes! - -## Why recreate the Studio interface? - -Closely replicating the built-in user interface gives user plugins a familiar feel. Studio users already recognise these interface items and understand both what they signify and how to use them. - -Designing a plugin to fit in seamlessly with the rest of Studio also offers a more coherent user experience with less visual distraction and friction when switching between third-party and built-in tools. - -## Plugins created with StudioComponents - -The coherence benefit also applies between multiple user plugins. It is preferable for user plugins to share a general appearance rather than every plugin being visually different. - -Using StudioComponents will align the appearance of a plugin with existing plugins, including: - -- [Collision Groups Editor](https://github.com/sircfenner/CollisionGroupsEditor), an alternative to Studio's built-in Collision Groups Editor - -- [Layers](https://github.com/call23re/Layers), a tool for manipulating and visualizing logical sections of 3D models - -If your plugin belongs on this list, file an [issue](https://github.com/sircfenner/StudioComponents/issues)! \ No newline at end of file diff --git a/docs/intro.md b/docs/intro.md new file mode 100644 index 0000000..2e67f32 --- /dev/null +++ b/docs/intro.md @@ -0,0 +1,49 @@ +--- +sidebar_position: 1 +--- + +# About + +This is a collection of React components for building Roblox Studio plugins. These include common user interface components found in Studio and are made to closely match the look and functionality of their built-in counterparts, including synchronizing with the user's selected theme. + +These components are built for [react-lua](https://github.com/jsdotlua/react-lua), Roblox's translation of ReactJS v17 into Luau. A prior version of this project (before v1.0.0) used [Roact](https://github.com/Roblox/roact) and had multiple API differences. For more information, see the [Changelog](../changelog). + +:::note +These components are only suitable for use in plugins. This is because they rely on plugin- or Studio-only APIs. +::: + +## Why recreate the Studio interface? + +Closely replicating the built-in user interface has two main advantages: + +1. Roblox Studio users recognise these components and know how to use them. +2. Less adjustment required when switching between third-party and built-in interfaces. + +The design of some built-in user interface components has changed in the lifetime of this +project. In some cases, these changes had negative implications for accessiblity or consistency so +their previous versions are used here instead. + +## Plugins created with StudioComponents + +With wider adoption, using these components to build a plugin will also align it with other third-party plugins in appearance, familiarity, and usability. + +Some plugins created with StudioComponents include: + +- [Archimedes 3](https://devforum.roblox.com/t/introducing-archimedes-3-a-building-plugin/1610366), a popular building plugin used to create smooth arcs +- [Collision Groups Editor](https://github.com/sircfenner/CollisionGroupsEditor), an alternative to the built-in editor for Collision Groups +- [Layers](https://github.com/call23re/Layers), a tool for working with logical sections of 3D models +- [Benchmarker](https://devforum.roblox.com/t/benchmarker-plugin-compare-function-speeds-with-graphs-percentiles-and-more/829912), a performance benchmarking tool for Luau code +- [LampLight](https://devforum.roblox.com/t/lamplight-global-illumination-for-roblox-new-v12/1837877), a tool for baking Global Illumination bounce lighting into scenes +- [MeshVox](https://devforum.roblox.com/t/meshvox-v10-a-powerful-3d-smooth-terrain-importstamping-tool/2576245), a smooth terrain importing and stamping tool + +:::info +Some of these plugins were built with the earlier Roact version (version 0.x, before react-lua was adopted) or the [Fusion port](https://github.com/mvyasu/PluginEssentials) of it. +::: + +## Migrating from Roact StudioComponents + +Existing users of the Roact version looking to migrate their project to React and the current version of StudioComponents should: + +1. Follow the react-lua [guide for migrating from Roact](https://jsdotlua.github.io/react-lua/migrating-from-legacy/minimum-requirements/) +2. Follow this project's [installation guide](./getting-started) +3. Address any [API differences](../changelog) between legacy StudioComponents and this version diff --git a/foreman.toml b/foreman.toml new file mode 100644 index 0000000..a2bc4c25 --- /dev/null +++ b/foreman.toml @@ -0,0 +1,7 @@ +[tools] +darklua = { github = "seaofvoices/darklua", version = "=0.13.0"} +luau-lsp = { github = "JohnnyMorganz/luau-lsp", version = "=1.28.1"} +rojo = { github = "rojo-rbx/rojo", version = "=7.4.1"} +selene = { github = "Kampfkarren/selene", version = "=0.27.1"} +stylua = { github = "JohnnyMorganz/StyLua", version = "=0.20.0"} +wally = { github = "UpliftGames/wally", version = "=0.3.2" } diff --git a/mkdocs.yml b/mkdocs.yml deleted file mode 100644 index 8b865fb..0000000 --- a/mkdocs.yml +++ /dev/null @@ -1,38 +0,0 @@ -site_name: StudioComponents -site_url: https://sircfenner.github.io/StudioComponents/ - -repo_name: sircfenner/StudioComponents -repo_url: https://github.com/sircfenner/StudioComponents - -theme: - name: material - palette: - scheme: slate - primary: teal - accent: teal - -extra_css: - - extra.css - -nav: - - Home: index.md - - Guide: - - Installation: guide/installation.md - - Usage: guide/usage.md - - Components: - - Background: components/background.md - - Button: components/button.md - - Checkbox: components/checkbox.md - - ColorPicker: components/colorpicker.md - - Dropdown: components/dropdown.md - - Label: components/label.md - - MainButton: components/mainbutton.md - - ScrollFrame: components/scrollframe.md - - Slider: components/slider.md - - TextInput: components/textinput.md - - VerticalCollapsibleSection: components/verticalcollapsiblesection.md - - VerticalExpandingList: components/verticalexpandinglist.md - - Widget: components/widget.md - -markdown_extensions: - - admonition \ No newline at end of file diff --git a/model.project.json b/model.project.json new file mode 100644 index 0000000..7d9c8ab --- /dev/null +++ b/model.project.json @@ -0,0 +1,9 @@ +{ + "name": "studiocomponents", + "tree": { + "$path": "src", + "node_modules": { + "$path": "node_modules" + } + } +} diff --git a/moonwave.toml b/moonwave.toml new file mode 100644 index 0000000..82476f8 --- /dev/null +++ b/moonwave.toml @@ -0,0 +1,30 @@ +title = "StudioComponents" +gitRepoUrl = "https://github.com/sircfenner/StudioComponents" +organizationName = "sircfenner" +projectName = "StudioComponents" +gitSourceBranch = "main" + +[docusaurus] +tagline = "React components for building Roblox Studio plugins" + +[footer] +copyright = "Copyright © 2024 sircfenner. Built with Moonwave and Docusaurus." + +[home] +enabled = true +includeReadme = false + +[[classOrder]] +section = "General" +collapsed = false +classes = ["Constants", "CommonProps"] + +[[classOrder]] +section = "Components" +collapsed = false +classes = ["Background", "Button", "Checkbox", "ColorPicker", "Dropdown", "DropShadowFrame", "Label", "LoadingDots", "MainButton", "NumberSequencePicker", "NumericInput", "PluginProvider", "ProgressBar", "RadioButton", "ScrollFrame", "Slider", "Splitter", "TabContainer", "TextInput"] + +[[classOrder]] +section = "Hooks" +collapsed = false +classes = ["useMouseIcon", "useTheme"] \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..871ceb5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,170 @@ +{ + "name": "@sircfenner/studiocomponents", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@sircfenner/studiocomponents", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@jsdotlua/react": "^17.1.0", + "@jsdotlua/react-roblox": "^17.1.0" + }, + "devDependencies": { + "npmluau": "^0.1.1" + } + }, + "node_modules/@jsdotlua/boolean": { + "version": "1.2.6", + "license": "MIT", + "dependencies": { + "@jsdotlua/number": "^1.2.6" + } + }, + "node_modules/@jsdotlua/collections": { + "version": "1.2.6", + "license": "MIT", + "dependencies": { + "@jsdotlua/es7-types": "^1.2.6", + "@jsdotlua/instance-of": "^1.2.6" + } + }, + "node_modules/@jsdotlua/console": { + "version": "1.2.6", + "license": "MIT", + "dependencies": { + "@jsdotlua/collections": "^1.2.6" + } + }, + "node_modules/@jsdotlua/es7-types": { + "version": "1.2.6", + "license": "MIT" + }, + "node_modules/@jsdotlua/instance-of": { + "version": "1.2.6", + "license": "MIT" + }, + "node_modules/@jsdotlua/luau-polyfill": { + "version": "1.2.6", + "license": "MIT", + "dependencies": { + "@jsdotlua/boolean": "^1.2.6", + "@jsdotlua/collections": "^1.2.6", + "@jsdotlua/console": "^1.2.6", + "@jsdotlua/es7-types": "^1.2.6", + "@jsdotlua/instance-of": "^1.2.6", + "@jsdotlua/math": "^1.2.6", + "@jsdotlua/number": "^1.2.6", + "@jsdotlua/string": "^1.2.6", + "@jsdotlua/timers": "^1.2.6", + "symbol-luau": "^1.0.0" + } + }, + "node_modules/@jsdotlua/math": { + "version": "1.2.6", + "license": "MIT" + }, + "node_modules/@jsdotlua/number": { + "version": "1.2.6", + "license": "MIT" + }, + "node_modules/@jsdotlua/promise": { + "version": "3.5.0", + "license": "MIT" + }, + "node_modules/@jsdotlua/react": { + "version": "17.1.0", + "license": "MIT", + "dependencies": { + "@jsdotlua/luau-polyfill": "^1.2.6", + "@jsdotlua/shared": "^17.1.0" + } + }, + "node_modules/@jsdotlua/react-reconciler": { + "version": "17.1.0", + "license": "MIT", + "dependencies": { + "@jsdotlua/luau-polyfill": "^1.2.6", + "@jsdotlua/promise": "^3.5.0", + "@jsdotlua/react": "^17.1.0", + "@jsdotlua/scheduler": "^17.1.0", + "@jsdotlua/shared": "^17.1.0" + } + }, + "node_modules/@jsdotlua/react-roblox": { + "version": "17.1.0", + "license": "MIT", + "dependencies": { + "@jsdotlua/luau-polyfill": "^1.2.6", + "@jsdotlua/react": "^17.1.0", + "@jsdotlua/react-reconciler": "^17.1.0", + "@jsdotlua/scheduler": "^17.1.0", + "@jsdotlua/shared": "^17.1.0" + } + }, + "node_modules/@jsdotlua/scheduler": { + "version": "17.1.0", + "license": "MIT", + "dependencies": { + "@jsdotlua/luau-polyfill": "^1.2.6", + "@jsdotlua/shared": "^17.1.0" + } + }, + "node_modules/@jsdotlua/shared": { + "version": "17.1.0", + "license": "MIT", + "dependencies": { + "@jsdotlua/luau-polyfill": "^1.2.6" + } + }, + "node_modules/@jsdotlua/string": { + "version": "1.2.6", + "license": "MIT", + "dependencies": { + "@jsdotlua/es7-types": "^1.2.6", + "@jsdotlua/number": "^1.2.6" + } + }, + "node_modules/@jsdotlua/timers": { + "version": "1.2.6", + "license": "MIT", + "dependencies": { + "@jsdotlua/collections": "^1.2.6" + } + }, + "node_modules/commander": { + "version": "11.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/npmluau": { + "version": "0.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^11.0.0", + "walkdir": "^0.4.1" + }, + "bin": { + "npmluau": "main.js" + } + }, + "node_modules/symbol-luau": { + "version": "1.0.1", + "license": "MIT" + }, + "node_modules/walkdir": { + "version": "0.4.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2f683bb --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "@sircfenner/studiocomponents", + "version": "1.0.0", + "description": "React components for building Roblox Studio plugins", + "license": "MIT", + "author": "sircfenner ", + "homepage": "https://github.com/sircfenner/studiocomponents#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/sircfenner/studiocomponents.git" + }, + "main": "src/init.luau", + "scripts": { + "build-assets": "sh ./scripts/build-assets.sh", + "serve": "sh ./scripts/serve.sh", + "clean": "rm -rf node_modules build serve temp darklua-sourcemap.json", + "format": "stylua .", + "lint": "sh ./scripts/analyze.sh && selene src", + "lint:luau": "sh ./scripts/analyze.sh", + "lint:selene": "selene src", + "prepare": "npmluau", + "style-check": "stylua . --check", + "verify-pack": "npm pack --dry-run" + }, + "dependencies": { + "@jsdotlua/react": "^17.1.0", + "@jsdotlua/react-roblox": "^17.1.0" + }, + "devDependencies": { + "npmluau": "^0.1.1" + }, + "keywords": [ + "luau" + ] +} diff --git a/scripts/analyze.sh b/scripts/analyze.sh new file mode 100755 index 0000000..6708e88 --- /dev/null +++ b/scripts/analyze.sh @@ -0,0 +1,13 @@ +#!/bin/sh + +set -e + +TYPES_FILE=globalTypes.d.lua + +if [ ! -f "$TYPES_FILE" ]; then + curl https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/main/scripts/globalTypes.d.lua > $TYPES_FILE +fi + +luau-lsp analyze --base-luaurc=.luaurc --settings=.luau-analyze.json \ + --definitions=$TYPES_FILE \ + src diff --git a/scripts/build-assets.sh b/scripts/build-assets.sh new file mode 100755 index 0000000..575098a --- /dev/null +++ b/scripts/build-assets.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +set -e + +scripts/build-roblox-model.sh .darklua.json build/studiocomponents.rbxm +scripts/build-wally-package.sh diff --git a/scripts/build-roblox-model.sh b/scripts/build-roblox-model.sh new file mode 100755 index 0000000..954cd16 --- /dev/null +++ b/scripts/build-roblox-model.sh @@ -0,0 +1,35 @@ +#!/bin/sh + +set -e + +DARKLUA_CONFIG=$1 +BUILD_OUTPUT=$2 +SOURCEMAP=darklua-sourcemap.json +TEMP_DIR=temp + +scripts/install-deps.sh + +rm -rf $TEMP_DIR +mkdir -p $TEMP_DIR + +cp -r src/ $TEMP_DIR/ +cp -rL node_modules/ $TEMP_DIR/ + +cp "$DARKLUA_CONFIG" "$TEMP_DIR/$DARKLUA_CONFIG" +rojo sourcemap model.project.json -o $TEMP_DIR/$SOURCEMAP + +cd $TEMP_DIR + +darklua process --config "$DARKLUA_CONFIG" src src +darklua process --config "$DARKLUA_CONFIG" node_modules node_modules + +cd .. + +cp model.project.json $TEMP_DIR/ + +rm -f "$BUILD_OUTPUT" +mkdir -p $(dirname "$BUILD_OUTPUT") + +rojo build $TEMP_DIR/model.project.json -o "$BUILD_OUTPUT" + +rm -rf $TEMP_DIR \ No newline at end of file diff --git a/scripts/build-wally-package.sh b/scripts/build-wally-package.sh new file mode 100755 index 0000000..5f492fe --- /dev/null +++ b/scripts/build-wally-package.sh @@ -0,0 +1,30 @@ +#!/bin/sh + +set -e + +TEMP_DIR=temp +WALLY_PACKAGE=build/wally + +scripts/install-deps.sh + +rm -rf $TEMP_DIR +mkdir -p $TEMP_DIR + +cp -r src $TEMP_DIR/src +rm -rf $WALLY_PACKAGE + +mkdir -p $WALLY_PACKAGE +cp LICENSE $WALLY_PACKAGE/LICENSE + +node ./scripts/npm-to-wally.js package.json $WALLY_PACKAGE/wally.toml $WALLY_PACKAGE/default.project.json $TEMP_DIR/wally-package.project.json + +cp .darklua-wally.json $TEMP_DIR +cp -r node_modules/.luau-aliases/* $TEMP_DIR + +rojo sourcemap $TEMP_DIR/wally-package.project.json --output $TEMP_DIR/sourcemap.json + +darklua process --config $TEMP_DIR/.darklua-wally.json $TEMP_DIR/src $WALLY_PACKAGE/src + +rm -rf $TEMP_DIR + +wally package --project-path $WALLY_PACKAGE --list \ No newline at end of file diff --git a/scripts/install-deps.sh b/scripts/install-deps.sh new file mode 100755 index 0000000..70d5a8f --- /dev/null +++ b/scripts/install-deps.sh @@ -0,0 +1,11 @@ +#!/bin/sh + +set -e + +if [ ! -d node_modules ]; then + npm install +fi + +if [ ! -d node_modules/.luau-aliases ]; then + npm run prepare +fi \ No newline at end of file diff --git a/scripts/npm-to-wally.js b/scripts/npm-to-wally.js new file mode 100644 index 0000000..ea98eac --- /dev/null +++ b/scripts/npm-to-wally.js @@ -0,0 +1,166 @@ +#!/usr/bin/env node + +/* +adapted from: https://github.com/jsdotlua/dom-testing-library-lua/blob/main/scripts/npm-to-wally.js + +changes: +- remove workspaces logic not required by this repository +- mirror description field from package.json to wally.toml + +*/ + +const { Command } = require("commander"); + +const fs = require("fs").promises; +const path = require("path"); +const process = require("process"); + +const extractPackageNameWhenScoped = (packageName) => + packageName.startsWith("@") + ? packageName.substring(packageName.indexOf("/") + 1) + : packageName; + +const readPackageConfig = async (packagePath) => { + const packageContent = await fs.readFile(packagePath).catch((err) => { + console.error( + `unable to read package.json at '${packagePath}': ${err}` + ); + return null; + }); + + if (packageContent !== null) { + try { + const packageData = JSON.parse(packageContent); + return packageData; + } catch (error) { + console.error( + `unable to parse package.json at '${packagePath}': ${err}` + ); + } + } + + return null; +}; + +const main = async ( + packageJsonPath, + wallyOutputPath, + wallyRojoConfigPath, + rojoConfigPath +) => { + const packageData = await readPackageConfig(packageJsonPath); + + const { + name: scopedName, + version, + license, + dependencies = [], + description, + } = packageData; + + const tomlLines = ["[package]", `name = "${scopedName.substring(1)}"`]; + + if (description) { + tomlLines.push(`description = "${description}"`); + } + + tomlLines.push( + `version = "${version}"`, + 'registry = "https://github.com/UpliftGames/wally-index"', + 'realm = "shared"', + `license = "${license}"`, + "", + "[dependencies]" + ); + + const rojoConfig = { + name: "WallyPackage", + tree: { + $className: "Folder", + Package: { + $path: "src", + }, + }, + }; + + for (const [dependencyName, specifiedVersion] of Object.entries( + dependencies + )) { + const name = extractPackageNameWhenScoped(dependencyName); + rojoConfig.tree[name] = { + $path: `${dependencyName}.luau`, + }; + + const wallyPackageName = name.indexOf("-") !== -1 ? `"${name}"` : name; + if (specifiedVersion == "workspace:^") { + error("workspace version not supported"); + } else { + tomlLines.push( + `${wallyPackageName} = "jsdotlua/${name}@${specifiedVersion}"` + ); + } + } + + tomlLines.push(""); + + const wallyRojoConfig = { + name: scopedName.substring(scopedName.indexOf("/") + 1), + tree: { + $path: "src", + }, + }; + + await Promise.all([ + fs.writeFile(wallyOutputPath, tomlLines.join("\n")).catch((err) => { + console.error( + `unable to write wally config at '${wallyOutputPath}': ${err}` + ); + }), + fs + .writeFile(rojoConfigPath, JSON.stringify(rojoConfig, null, 2)) + .catch((err) => { + console.error( + `unable to write rojo config at '${rojoConfigPath}': ${err}` + ); + }), + fs + .writeFile( + wallyRojoConfigPath, + JSON.stringify(wallyRojoConfig, null, 2) + ) + .catch((err) => { + console.error( + `unable to write rojo config for wally at '${wallyRojoConfigPath}': ${err}` + ); + }), + ]); +}; + +const createCLI = () => { + const program = new Command(); + + program + .name("npm-to-wally") + .description("a utility to convert npm packages to wally packages") + .argument("") + .argument("") + .argument("") + .argument("") + .action(async (packageJson, wallyToml, wallyRojoConfig, rojoConfig) => { + const cwd = process.cwd(); + await main( + path.join(cwd, packageJson), + path.join(cwd, wallyToml), + path.join(cwd, wallyRojoConfig), + path.join(cwd, rojoConfig) + ); + }); + + return (args) => { + program.parse(args); + }; +}; + +const run = createCLI(); + +run(process.argv); diff --git a/scripts/serve.sh b/scripts/serve.sh new file mode 100755 index 0000000..ab08157 --- /dev/null +++ b/scripts/serve.sh @@ -0,0 +1,28 @@ +#!/bin/sh + +set -e + +DARKLUA_CONFIG=.darklua.json +SOURCEMAP=darklua-sourcemap.json +SERVE_DIR=serve + +scripts/install-deps.sh + +rm -f $SOURCEMAP +rm -rf $SERVE_DIR +mkdir -p $SERVE_DIR + +cp model.project.json $SERVE_DIR/model.project.json +cp serve.project.json $SERVE_DIR/serve.project.json +cp -r src $SERVE_DIR/src +cp -rL node_modules $SERVE_DIR/node_modules + +rojo sourcemap model.project.json -o $SOURCEMAP +#darklua process --config $DARKLUA_CONFIG src $SERVE_DIR/src +#darklua process --config $DARKLUA_CONFIG node_modules $SERVE_DIR/node_modules + +rojo sourcemap --watch model.project.json -o $SOURCEMAP & +darklua process -w --config $DARKLUA_CONFIG src $SERVE_DIR/src & +darklua process -w --config $DARKLUA_CONFIG node_modules $SERVE_DIR/node_modules & + +rojo serve $SERVE_DIR/serve.project.json diff --git a/selene.toml b/selene.toml index 1f1e170..aa278bb 100644 --- a/selene.toml +++ b/selene.toml @@ -1 +1,2 @@ -std = "roblox" \ No newline at end of file +std = "selene_defs" + diff --git a/selene_defs.yml b/selene_defs.yml new file mode 100644 index 0000000..95cabdf --- /dev/null +++ b/selene_defs.yml @@ -0,0 +1,7 @@ +base: roblox +name: selene_defs +globals: + # override Roblox require style with string requires + require: + args: + - type: string diff --git a/serve.project.json b/serve.project.json new file mode 100644 index 0000000..92b2f66 --- /dev/null +++ b/serve.project.json @@ -0,0 +1,11 @@ +{ + "name": "studiocomponents-dev", + "tree": { + "$className": "DataModel", + "ServerStorage": { + "studiocomponents": { + "$path": "model.project.json" + } + } + } +} diff --git a/src/Background.lua b/src/Background.lua deleted file mode 100644 index 8261da8..0000000 --- a/src/Background.lua +++ /dev/null @@ -1,22 +0,0 @@ -local Packages = script.Parent.Parent - -local Roact = require(Packages.Roact) -local Hooks = require(Packages.RoactHooks) - -local useTheme = require(script.Parent.useTheme) - -local function Background(props, hooks) - local theme = useTheme(hooks) - - return Roact.createElement("Frame", { - Size = props.Size or UDim2.fromScale(1, 1), - Position = props.Position or UDim2.fromScale(0, 0), - AnchorPoint = props.AnchorPoint or Vector2.new(0, 0), - LayoutOrder = props.LayoutOrder or 0, - ZIndex = props.ZIndex or 1, - BorderSizePixel = 0, - BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.MainBackground), - }, props[Roact.Children]) -end - -return Hooks.new(Roact)(Background) diff --git a/src/Background.story.lua b/src/Background.story.lua deleted file mode 100644 index 9327c45..0000000 --- a/src/Background.story.lua +++ /dev/null @@ -1,12 +0,0 @@ -local Packages = script.Parent.Parent -local Roact = require(Packages.Roact) - -local Background = require(script.Parent.Background) - -return function(target) - local element = Roact.createElement(Background) - local handle = Roact.mount(element, target) - return function() - Roact.unmount(handle) - end -end diff --git a/src/BaseButton.lua b/src/BaseButton.lua deleted file mode 100644 index 147feee..0000000 --- a/src/BaseButton.lua +++ /dev/null @@ -1,97 +0,0 @@ -local Packages = script.Parent.Parent - -local Roact = require(Packages.Roact) -local Hooks = require(Packages.RoactHooks) - -local joinDictionaries = require(script.Parent.joinDictionaries) -local useTheme = require(script.Parent.useTheme) - -local Constants = require(script.Parent.Constants) - -local defaultProps = { - LayoutOrder = 0, - Disabled = false, - Selected = false, - Position = UDim2.fromScale(0, 0), - AnchorPoint = Vector2.new(0, 0), - Size = UDim2.fromScale(1, 1), - Text = "Button.defaultProps.Text", - TextColorStyle = Enum.StudioStyleGuideColor.ButtonText, - BackgroundColorStyle = Enum.StudioStyleGuideColor.Button, - BorderColorStyle = Enum.StudioStyleGuideColor.ButtonBorder, - OnActivated = function() end, -} - -local propsToScrub = { - Disabled = Roact.None, - Selected = Roact.None, - TextColorStyle = Roact.None, - BackgroundColorStyle = Roact.None, - BorderColorStyle = Roact.None, - OnActivated = Roact.None, -} - -local function BaseButton(props, hooks) - local theme = useTheme(hooks) - - local hovered, setHovered = hooks.useState(false) - local pressed, setPressed = hooks.useState(false) - - local onInputBegan = function(_, inputObject) - if props.Disabled then - return - elseif inputObject.UserInputType == Enum.UserInputType.MouseMovement then - setHovered(true) - elseif inputObject.UserInputType == Enum.UserInputType.MouseButton1 then - setPressed(true) - end - end - - local onInputEnded = function(_, inputObject) - if props.Disabled then - return - elseif inputObject.UserInputType == Enum.UserInputType.MouseMovement then - setHovered(false) - elseif inputObject.UserInputType == Enum.UserInputType.MouseButton1 then - setPressed(false) - end - end - - local onActivated = function() - if not props.Disabled then - setHovered(false) - setPressed(false) - props.OnActivated() - end - end - - local modifier = Enum.StudioStyleGuideModifier.Default - if props.Disabled then - modifier = Enum.StudioStyleGuideModifier.Disabled - elseif props.Selected then - modifier = Enum.StudioStyleGuideModifier.Selected - elseif pressed then - modifier = Enum.StudioStyleGuideModifier.Pressed - elseif hovered then - modifier = Enum.StudioStyleGuideModifier.Hover - end - - local scrubbedProps = joinDictionaries(props, propsToScrub, { - Font = Constants.Font, - TextSize = Constants.TextSize, - TextColor3 = theme:GetColor(props.TextColorStyle, modifier), - BackgroundColor3 = theme:GetColor(props.BackgroundColorStyle, modifier), - BorderColor3 = theme:GetColor(props.BorderColorStyle, modifier), - BorderMode = Enum.BorderMode.Inset, - AutoButtonColor = false, - [Roact.Event.InputBegan] = onInputBegan, - [Roact.Event.InputEnded] = onInputEnded, - [Roact.Event.Activated] = onActivated, - }) - - return Roact.createElement("TextButton", scrubbedProps) -end - -return Hooks.new(Roact)(BaseButton, { - defaultProps = defaultProps, -}) diff --git a/src/Button.lua b/src/Button.lua deleted file mode 100644 index 20b268e..0000000 --- a/src/Button.lua +++ /dev/null @@ -1,18 +0,0 @@ -local Packages = script.Parent.Parent -local Roact = require(Packages.Roact) - -local joinDictionaries = require(script.Parent.joinDictionaries) -local BaseButton = require(script.Parent.BaseButton) - -local function Button(props) - return Roact.createElement( - BaseButton, - joinDictionaries({ - TextColorStyle = Enum.StudioStyleGuideColor.ButtonText, - BackgroundColorStyle = Enum.StudioStyleGuideColor.Button, - BorderColorStyle = Enum.StudioStyleGuideColor.ButtonBorder, - }, props) - ) -end - -return Button diff --git a/src/Button.story.lua b/src/Button.story.lua deleted file mode 100644 index 4f9c254..0000000 --- a/src/Button.story.lua +++ /dev/null @@ -1,39 +0,0 @@ -local Packages = script.Parent.Parent -local Roact = require(Packages.Roact) - -local Button = require(script.Parent.Button) - -return function(target) - local element = Roact.createFragment({ - Layout = Roact.createElement("UIListLayout", { - Padding = UDim.new(0, 5), - SortOrder = Enum.SortOrder.LayoutOrder, - FillDirection = Enum.FillDirection.Vertical, - HorizontalAlignment = Enum.HorizontalAlignment.Center, - VerticalAlignment = Enum.VerticalAlignment.Center, - }), - Button0 = Roact.createElement(Button, { - LayoutOrder = 0, - Size = UDim2.fromOffset(100, 32), - Text = "Enabled", - OnActivated = function() end, - }), - Button1 = Roact.createElement(Button, { - LayoutOrder = 1, - Size = UDim2.fromOffset(100, 32), - Text = "Selected", - Selected = true, - OnActivated = function() end, - }), - Button2 = Roact.createElement(Button, { - LayoutOrder = 2, - Size = UDim2.fromOffset(100, 32), - Text = "Disabled", - Disabled = true, - }), - }) - local handle = Roact.mount(element, target) - return function() - Roact.unmount(handle) - end -end diff --git a/src/Checkbox.lua b/src/Checkbox.lua deleted file mode 100644 index 8da41cd..0000000 --- a/src/Checkbox.lua +++ /dev/null @@ -1,129 +0,0 @@ -local Packages = script.Parent.Parent - -local Roact = require(Packages.Roact) -local Hooks = require(Packages.RoactHooks) - -local useTheme = require(script.Parent.useTheme) - -local Constants = require(script.Parent.Constants) - -local INDICATOR_IMAGE = "rbxassetid://6652838434" - -local defaultProps = { - Alignment = Constants.CheckboxAlignment.Left, -} - -local function Checkbox(props, hooks) - local theme = useTheme(hooks) - local hovered, setHovered = hooks.useState(false) - - local onInputBegan = function(_, inputObject) - if props.Disabled then - return - elseif inputObject.UserInputType == Enum.UserInputType.MouseMovement then - setHovered(true) - end - end - - local onInputEnded = function(_, inputObject) - if props.Disabled then - return - elseif inputObject.UserInputType == Enum.UserInputType.MouseMovement then - setHovered(false) - end - end - - local onActivated = function() - if not props.Disabled then - props.OnActivated() - end - end - - local mainModifier = Enum.StudioStyleGuideModifier.Default - if props.Disabled then - mainModifier = Enum.StudioStyleGuideModifier.Disabled - elseif hovered then - mainModifier = Enum.StudioStyleGuideModifier.Hover - end - - local backModifier = Enum.StudioStyleGuideModifier.Default - if props.Disabled then - backModifier = Enum.StudioStyleGuideModifier.Disabled - elseif props.Value == true then - backModifier = Enum.StudioStyleGuideModifier.Selected - end - - local boxPositionX = 0 - local textPositionX = 1 - local textAlign = Enum.TextXAlignment.Left - if props.Alignment == Constants.CheckboxAlignment.Right then - boxPositionX = 1 - textPositionX = 0 - textAlign = Enum.TextXAlignment.Right - end - - local rectOffset = Vector2.new(0, 0) - if props.Value == Constants.CheckboxIndeterminate then - if tostring(theme) == "Dark" then -- this is a hack - rectOffset = Vector2.new(13, 0) - else - rectOffset = Vector2.new(26, 0) - end - end - - local indicatorColor = theme:GetColor(Enum.StudioStyleGuideColor.CheckedFieldIndicator, mainModifier) - if props.Value == Constants.CheckboxIndeterminate then - indicatorColor = Color3.fromRGB(255, 255, 255) - end - - return Roact.createElement("Frame", { - Size = UDim2.new(1, 0, 0, 15), - BackgroundTransparency = 1, - LayoutOrder = props.LayoutOrder, - ZIndex = props.ZIndex, - }, { - Button = Roact.createElement("TextButton", { - Text = "", - Size = UDim2.fromScale(1, 1), - BackgroundTransparency = 1, - [Roact.Event.InputBegan] = onInputBegan, - [Roact.Event.InputEnded] = onInputEnded, - [Roact.Event.Activated] = onActivated, - }), - Box = Roact.createElement("Frame", { - AnchorPoint = Vector2.new(boxPositionX, 0), - Position = UDim2.fromScale(boxPositionX, 0), - BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.CheckedFieldBackground, backModifier), - BorderColor3 = theme:GetColor(Enum.StudioStyleGuideColor.CheckedFieldBorder, mainModifier), - BorderMode = Enum.BorderMode.Inset, - Size = UDim2.fromOffset(15, 15), - }, { - Indicator = props.Value ~= false and Roact.createElement("ImageLabel", { - Position = UDim2.fromOffset(0, 0), - BackgroundTransparency = 1, - Size = UDim2.fromOffset(13, 13), - Image = INDICATOR_IMAGE, - ImageColor3 = indicatorColor, - ImageRectOffset = rectOffset, - ImageRectSize = Vector2.new(13, 13), - }), - }), - Label = props.Label and Roact.createElement("TextLabel", { - BackgroundTransparency = 1, - AnchorPoint = Vector2.new(textPositionX, 0), - Position = UDim2.fromScale(textPositionX, 0), - Size = UDim2.new(1, -20, 1, 0), - TextXAlignment = textAlign, - TextTruncate = Enum.TextTruncate.AtEnd, - Text = props.Label, - Font = Constants.Font, - TextSize = Constants.TextSize, - TextColor3 = theme:GetColor(Enum.StudioStyleGuideColor.MainText, mainModifier), - }), - Children = Roact.createFragment(props[Roact.Children]), - }) -end - -return Hooks.new(Roact)(Checkbox, { - defaultProps = defaultProps, -}) diff --git a/src/Checkbox.story.lua b/src/Checkbox.story.lua deleted file mode 100644 index c8c3760..0000000 --- a/src/Checkbox.story.lua +++ /dev/null @@ -1,97 +0,0 @@ -local Packages = script.Parent.Parent -local Roact = require(Packages.Roact) - -local Checkbox = require(script.Parent.Checkbox) -local Constants = require(script.Parent.Constants) - -local Wrapper = Roact.Component:extend("CheckboxWrapper") - -function Wrapper:init() - self:setState({ - Value0 = true, - Value1 = false, - }) -end - -function Wrapper:render() - local state = self.state - local value2 = Constants.CheckboxIndeterminate - if state.Value0 == state.Value1 then - value2 = state.Value0 - end - return Roact.createElement("Frame", { - AnchorPoint = Vector2.new(0.5, 0.5), - Position = UDim2.fromScale(0.5, 0.5), - Size = UDim2.new(0, 200, 1, 0), - BackgroundTransparency = 1, - }, { - Layout = Roact.createElement("UIListLayout", { - Padding = UDim.new(0, 5), - SortOrder = Enum.SortOrder.LayoutOrder, - FillDirection = Enum.FillDirection.Vertical, - VerticalAlignment = Enum.VerticalAlignment.Center, - }), - Checkbox0 = Roact.createElement(Checkbox, { - LayoutOrder = 0, - Value = state.Value0, - Label = "Value0", - OnActivated = function() - self:setState({ Value0 = not state.Value0 }) - end, - }), - Checkbox1 = Roact.createElement(Checkbox, { - LayoutOrder = 1, - Value = state.Value1, - Label = "Value1", - OnActivated = function() - self:setState({ Value1 = not state.Value1 }) - end, - }), - Checkbox2 = Roact.createElement(Checkbox, { - LayoutOrder = 2, - Value = value2, - Label = "Value0 & Value1", - OnActivated = function() - local nextValue = true - if state.Value0 == state.Value1 then - nextValue = not state.Value0 - end - self:setState({ - Value0 = nextValue, - Value1 = nextValue, - }) - end, - }), - Padding = Roact.createElement("Frame", { - BackgroundTransparency = 1, - Size = UDim2.new(1, 0, 0, 12), - LayoutOrder = 3, - }), - Checkbox3 = Roact.createElement(Checkbox, { - LayoutOrder = 4, - Value = true, - Disabled = true, - Label = "Disabled, true", - }), - Checkbox4 = Roact.createElement(Checkbox, { - LayoutOrder = 5, - Value = false, - Disabled = true, - Label = "Disabled, false", - }), - Checkbox5 = Roact.createElement(Checkbox, { - LayoutOrder = 6, - Value = Constants.CheckboxIndeterminate, - Disabled = true, - Label = "Disabled, indeterminate", - }), - }) -end - -return function(target) - local element = Roact.createElement(Wrapper) - local handle = Roact.mount(element, target) - return function() - Roact.unmount(handle) - end -end diff --git a/src/ColorPicker.lua b/src/ColorPicker.lua deleted file mode 100644 index 2af8912..0000000 --- a/src/ColorPicker.lua +++ /dev/null @@ -1,153 +0,0 @@ -local Packages = script.Parent.Parent - -local Roact = require(Packages.Roact) -local Hooks = require(Packages.RoactHooks) - -local useTheme = require(script.Parent.useTheme) -local useDragInput = require(script.Parent.useDragInput) - -local function generateHueKeypoints(value) - local keypoints = {} - for hue = 0, 6 do - table.insert(keypoints, ColorSequenceKeypoint.new(hue / 6, Color3.fromHSV((6 - hue) / 6, 1, value))) - end - return ColorSequence.new(keypoints) -end - -local defaultProps = { - Size = UDim2.fromOffset(250, 200), -} - -local function ColorPicker(props, hooks) - local theme = useTheme(hooks) - - -- Color3 does not retain HSV data at all. For example: - -- Color3.fromHSV(1, 0, 0):ToHSV() -> (0, 0, 0) - -- or Color3.fromHSV(1, 1, 1):ToHSV() -> (0, 1, 1) - -- Since information is lost leads to cases like: - -- * value being zeroed causes the picker's position to snap a corner. - -- * leading the picker to the right side (sat = zero) causes the picker to wrap around. - -- * and more! - - -- Using self.state isn't possible since :willUpdate() cannot change state. - local hsv = hooks.useValue({ props.Color:ToHSV() }) - - -- This will always ensure we're never out of sync. - -- Use a dead-simple check to see if our values don't match. - if Color3.fromHSV(unpack(hsv.value)) ~= props.Color then - hsv.value = { props.Color:ToHSV() } - end - - local regionDrag = useDragInput(hooks, function(region, position) - local alpha = (position - region.AbsolutePosition) / region.AbsoluteSize - local newHue = math.clamp(1 - alpha.x, 0, 1) - local newSat = math.clamp(1 - alpha.y, 0, 1) - local newVal = hsv.value[3] - hsv.value[1] = newHue - hsv.value[2] = newSat - props.OnChange(Color3.fromHSV(newHue, newSat, newVal)) - end) - - local barDrag = useDragInput(hooks, function(bar, position) - local alpha = (position - bar.AbsolutePosition) / bar.AbsoluteSize - - local newHue = hsv.value[1] - local newSat = hsv.value[2] - local newVal = math.clamp(1 - alpha.y, 0, 1) - hsv.value[3] = newVal - - props.OnChange(Color3.fromHSV(newHue, newSat, newVal)) - end) - - local hue, sat, val = unpack(hsv.value) - local indicatorBackground = if val > 0.4 then Color3.new() else Color3.fromRGB(200, 200, 200) - - return Roact.createElement("Frame", { - Size = props.Size, - Position = props.Position, - AnchorPoint = props.AnchorPoint, - BackgroundTransparency = 1, - }, { - -- using TextButton prevents the studio drag-selection box appearing - Slider = Roact.createElement("TextButton", { - Active = false, - AutoButtonColor = false, - Text = "", - Size = UDim2.new(0, 14, 1, 0), - AnchorPoint = Vector2.new(1, 0), - Position = UDim2.new(1, -6, 0, 0), - BorderColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Border), - BackgroundColor3 = Color3.fromRGB(255, 255, 255), - [Roact.Event.InputBegan] = barDrag.onInputBegan, - [Roact.Event.InputEnded] = barDrag.onInputEnded, - }, { - Gradient = Roact.createElement("UIGradient", { - Color = ColorSequence.new(Color3.fromRGB(0, 0, 0), Color3.fromHSV(hue, sat, 1)), - Rotation = 270, - }), - Arrow = Roact.createElement("ImageLabel", { - AnchorPoint = Vector2.new(0, 0.5), - Size = UDim2.fromOffset(5, 9), - Position = UDim2.new(1, 1, 1 - val, 0), - BackgroundTransparency = 1, - Image = "rbxassetid://7507468017", - ImageColor3 = theme:GetColor(Enum.StudioStyleGuideColor.TitlebarText), - }), - }), - -- see above re: TextButton for why ImageButton is used here - Region = Roact.createElement("ImageButton", { - Active = false, - AutoButtonColor = false, - Size = UDim2.new(1, -30, 1, 0), - Image = "", - ClipsDescendants = true, - BorderColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Border), - [Roact.Event.InputBegan] = regionDrag.onInputBegan, - [Roact.Event.InputEnded] = regionDrag.onInputEnded, - }, { - Indicator = Roact.createElement("Frame", { - AnchorPoint = Vector2.new(0.5, 0.5), - Position = UDim2.new(1 - hue, 0, 1 - sat, 0), - Size = UDim2.fromOffset(20, 20), - BackgroundTransparency = 1, - }, { - Vertical = Roact.createElement("Frame", { - Position = UDim2.fromOffset(9, 0), - Size = UDim2.new(0, 2, 1, 0), - BorderSizePixel = 0, - BackgroundColor3 = indicatorBackground, - }), - Horizontal = Roact.createElement("Frame", { - Position = UDim2.fromOffset(0, 9), - Size = UDim2.new(1, 0, 0, 2), - BorderSizePixel = 0, - BackgroundColor3 = indicatorBackground, - }), - }), - HueGradient = Roact.createElement("Frame", { - BackgroundColor3 = Color3.fromRGB(255, 255, 255), - Size = UDim2.fromScale(1, 1), - ZIndex = -1, - }, { - Gradient = Roact.createElement("UIGradient", { - Color = generateHueKeypoints(val), - }), - }), - SaturationGradient = Roact.createElement("Frame", { - BackgroundColor3 = Color3.fromRGB(255, 255, 255), - Size = UDim2.fromScale(1, 1), - ZIndex = 0, - }, { - Gradient = Roact.createElement("UIGradient", { - Color = ColorSequence.new(Color3.fromHSV(1, 0, val)), - Transparency = NumberSequence.new(1, 0), - Rotation = 90, - }), - }), - }), - }) -end - -return Hooks.new(Roact)(ColorPicker, { - defaultProps = defaultProps, -}) diff --git a/src/ColorPicker.story.lua b/src/ColorPicker.story.lua deleted file mode 100644 index c198560..0000000 --- a/src/ColorPicker.story.lua +++ /dev/null @@ -1,57 +0,0 @@ -local Packages = script.Parent.Parent -local Roact = require(Packages.Roact) - -local ColorPicker = require(script.Parent.ColorPicker) -local Label = require(script.Parent.Label) - -local Wrapper = Roact.Component:extend("Wrapper") - -function Wrapper:init() - self:setState({ Color = Color3.fromRGB(128, 196, 92) }) -end - -function Wrapper:render() - local color = self.state.Color - return Roact.createFragment({ - Layout = Roact.createElement("UIListLayout", { - SortOrder = Enum.SortOrder.LayoutOrder, - FillDirection = Enum.FillDirection.Vertical, - HorizontalAlignment = Enum.HorizontalAlignment.Center, - VerticalAlignment = Enum.VerticalAlignment.Center, - Padding = UDim.new(0, 25), - }), - Picker = Roact.createElement(ColorPicker, { - Size = UDim2.fromOffset(250, 200), - Color = color, - OnChange = function(newColor) - self:setState({ Color = newColor }) - end, - }), - Swatch = Roact.createElement("TextLabel", { - LayoutOrder = 1, - Size = UDim2.fromOffset(250, 30), - BackgroundColor3 = color, - Font = Enum.Font.SourceSansBold, - TextSize = 24, - Text = string.format("%i, %i, %i", color.r * 255, color.g * 255, color.b * 255), - TextColor3 = Color3.fromRGB(255, 255, 255), - TextStrokeTransparency = 0.5, - }, { - Disclaimer = Roact.createElement(Label, { - Position = UDim2.fromScale(0, 1), - Size = UDim2.fromScale(1, 1), - BackgroundTransparency = 1, - TextColorStyle = Enum.StudioStyleGuideColor.SubText, - Text = "(not part of ColorPicker component)", - }), - }), - }) -end - -return function(target) - local element = Roact.createElement(Wrapper) - local handle = Roact.mount(element, target) - return function() - Roact.unmount(handle) - end -end diff --git a/src/CommonProps.luau b/src/CommonProps.luau new file mode 100644 index 0000000..ef3c848 --- /dev/null +++ b/src/CommonProps.luau @@ -0,0 +1,34 @@ +--[=[ + @class CommonProps + @private + + The props listed here are accepted by every component except where explicitly noted. + These props are accepted in addition to the props specified by components on their API pages. + + :::info + This file is not exported and serves only to host an internal type and documentation. + ::: +]=] + +--[=[ + @within CommonProps + @interface CommonProps + + @field Disabled boolean? + @field AnchorPoint Vector2? + @field Position UDim2? + @field Size UDim2? + @field LayoutOrder number? + @field ZIndex number? +]=] + +export type T = { + Disabled: boolean?, + AnchorPoint: Vector2?, + Position: UDim2?, + Size: UDim2?, + LayoutOrder: number?, + ZIndex: number?, +} + +return {} diff --git a/src/Components/Background.luau b/src/Components/Background.luau new file mode 100644 index 0000000..3cfbc48 --- /dev/null +++ b/src/Components/Background.luau @@ -0,0 +1,53 @@ +--[=[ + @class Background + + A borderless frame matching the default background color of Studio widgets. + + | Dark | Light | + | - | - | + | ![Dark](/components/background/dark.png) | ![Light](/components/background/light.png) | + + Any children passed will be parented to the frame, which makes it suitable for use as, + for example, the root component in a plugin Widget. For example: + + ```lua + local function MyComponent() + return React.createElement(StudioComponents.Background, {}, { + MyChild = React.createElement(...), + }) + end + ``` +]=] + +local React = require("@pkg/@jsdotlua/react") + +local CommonProps = require("../CommonProps") +local useTheme = require("../Hooks/useTheme") + +--[=[ + @within Background + @interface Props + @tag Component Props + + @field ... CommonProps + @field children React.ReactNode +]=] + +type BackgroundProps = CommonProps.T & { + children: React.ReactNode?, +} + +local function Background(props: BackgroundProps) + local theme = useTheme() + return React.createElement("Frame", { + BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.MainBackground), + BorderSizePixel = 0, + AnchorPoint = props.AnchorPoint, + Position = props.Position, + Size = props.Size or UDim2.fromScale(1, 1), + LayoutOrder = props.LayoutOrder, + ZIndex = props.ZIndex, + }, props.children) +end + +return Background diff --git a/src/Components/Button.luau b/src/Components/Button.luau new file mode 100644 index 0000000..5f82963 --- /dev/null +++ b/src/Components/Button.luau @@ -0,0 +1,77 @@ +--[=[ + @class Button + A basic button that supports text, an icon, or both. This should be used as a standalone button + or as a secondary button alongside a [MainButton] for the primary action in a group of options. + + | Dark | Light | + | - | - | + | ![Dark](/components/button/dark.png) | ![Light](/components/button/light.png) | + + The `OnActivated` prop should be a callback which is run when the button is clicked. + For example: + + ```lua + local function MyComponent() + return React.createElement(StudioComponents.Button, { + Text = "Click Me", + OnActivated = function() + print("Button clicked!") + end + }) + end + ``` + + The default size of buttons can be found in [Constants.DefaultButtonHeight]. To override this, + there are two main options, which may be combined: + 1. Pass a `Size` prop. + 2. Pass an `AutomaticSize` prop. + + AutomaticSize is a simpler version of Roblox's built-in AutomaticSize system. Passing a value of + `Enum.AutomaticSize.X` will override the button's width to fit the text and/or icon. Passing a + value of `Enum.AutomaticSize.Y` will do the same but with the button's height. Passing + `Enum.AutomaticSize.XY` will override both axes. +]=] + +local React = require("@pkg/@jsdotlua/react") + +local BaseButton = require("./Foundation/BaseButton") + +--[=[ + @within Button + @interface IconProps + + @field Image string + @field Size Vector2 + @field Transparency number? + @field Color Color3? + @field UseThemeColor boolean? + @field Alignment HorizontalAlignment? + + The `Alignment` prop is used to configure which side of any text the icon + appears on. Left-alignment is the default and center-alignment is not supported. + + When specifying icon color, at most one of `Color` and `UseThemeColor` should be specified. +]=] + +--[=[ + @within Button + @interface Props + @tag Component Props + + @field ... CommonProps + @field AutomaticSize AutomaticSize? + @field OnActivated (() -> ())? + @field Text string? + @field Icon IconProps? +]=] + +local function Button(props: BaseButton.BaseButtonConsumerProps) + local merged = table.clone(props) :: BaseButton.BaseButtonProps + merged.BackgroundColorStyle = Enum.StudioStyleGuideColor.Button + merged.BorderColorStyle = Enum.StudioStyleGuideColor.ButtonBorder + merged.TextColorStyle = Enum.StudioStyleGuideColor.ButtonText + + return React.createElement(BaseButton, merged) +end + +return Button diff --git a/src/Components/Checkbox.luau b/src/Components/Checkbox.luau new file mode 100644 index 0000000..a2d38aa --- /dev/null +++ b/src/Components/Checkbox.luau @@ -0,0 +1,129 @@ +--[=[ + @class Checkbox + + A box which can be checked or unchecked, usually used to toggle an option. Passing a value to + the `Label` prop is the recommended way to indicate the purpose of a checkbox. + + | Dark | Light | + | - | - | + | ![Dark](/components/checkbox/dark.png) | ![Light](/components/checkbox/light.png) | + + As this is a controlled component, you should pass a value to the `Value` prop representing + whether the box is checked, and a callback value to the `OnChanged` prop which gets run when + the user interacts with the checkbox. For example: + + ```lua + local function MyComponent() + local selected, setSelected = React.useState(false) + return React.createElement(StudioComponents.Checkbox, { + Value = selected, + OnChanged = setSelected, + }) + end + ``` + + The default height of a checkbox, including its label, can be found in [Constants.DefaultToggleHeight]. + The size of the whole checkbox can be overridden by passing a value to the `Size` prop. + + By default, the box and label are left-aligned within the parent frame. This can be overriden by + passing an [Enum.HorizontalAlignment] value to the `ContentAlignment` prop. + + By default, the box is placed to the left of the label. This can be overriden by passing either + `Enum.HorizontalAlignment.Left` or `Enum.HorizontalAlignment.Right` to the + `ButtonAlignment` prop. + + Checkboxes can also represent 'indeterminate' values, which indicates that it is neither + checked nor unchecked. This can be achieved by passing `nil` to the `Value` prop. + This might be used when a checkbox represents the combined state of two different options, one of + which has a value of `true` and the other `false`. + + :::info + The built-in Studio checkboxes were changed during this project's lifetime to be smaller and + have a lower contrast ratio, especially in Dark theme. This component retains the old design + as it is more accessible. + ::: +]=] + +local React = require("@pkg/@jsdotlua/react") + +local BaseLabelledToggle = require("./Foundation/BaseLabelledToggle") +local useTheme = require("../Hooks/useTheme") + +local INDICATOR_IMAGE = "rbxassetid://14890059620" + +--[=[ + @within Checkbox + @interface Props + @tag Component Props + + @field ... CommonProps + @field Value boolean? + @field OnChanged (() -> ())? + @field Label string? + @field ContentAlignment HorizontalAlignment? + @field ButtonAlignment HorizontalAlignment? +]=] + +type CheckboxProps = BaseLabelledToggle.BaseLabelledToggleConsumerProps & { + Value: boolean?, +} + +local function Checkbox(props: CheckboxProps) + local theme = useTheme() + local mergedProps = table.clone(props) :: BaseLabelledToggle.BaseLabelledToggleProps + + function mergedProps.RenderButton(subProps: { Hovered: boolean }) + local mainModifier = Enum.StudioStyleGuideModifier.Default + if props.Disabled then + mainModifier = Enum.StudioStyleGuideModifier.Disabled + elseif subProps.Hovered then + mainModifier = Enum.StudioStyleGuideModifier.Hover + end + + local backModifier = Enum.StudioStyleGuideModifier.Default + if props.Disabled then + backModifier = Enum.StudioStyleGuideModifier.Disabled + elseif props.Value == true then + backModifier = Enum.StudioStyleGuideModifier.Selected + elseif subProps.Hovered then + backModifier = Enum.StudioStyleGuideModifier.Hover + end + + local rectOffset = Vector2.new(0, 0) + if props.Value == nil then -- indeterminate + local background = theme:GetColor(Enum.StudioStyleGuideColor.MainBackground) + local _, _, val = background:ToHSV() + rectOffset = if val < 0.5 then Vector2.new(14, 0) else Vector2.new(28, 0) + end + + local indicatorColor = theme:GetColor(Enum.StudioStyleGuideColor.CheckedFieldIndicator, mainModifier) + local indicatorTransparency = 0 + if props.Value == nil then + indicatorColor = Color3.fromRGB(255, 255, 255) + if props.Disabled then + indicatorTransparency = 0.5 + end + end + + return React.createElement("Frame", { + BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.CheckedFieldBackground, backModifier), + BorderColor3 = theme:GetColor(Enum.StudioStyleGuideColor.CheckedFieldBorder, mainModifier), + BorderMode = Enum.BorderMode.Inset, + Size = UDim2.fromScale(1, 1), + }, { + Indicator = props.Value ~= false and React.createElement("ImageLabel", { + BackgroundTransparency = 1, + Size = UDim2.fromOffset(14, 14), + Image = INDICATOR_IMAGE, + ImageColor3 = indicatorColor, + ImageRectOffset = rectOffset, + ImageRectSize = Vector2.new(14, 14), + ImageTransparency = indicatorTransparency, + }), + }) + end + + return React.createElement(BaseLabelledToggle, mergedProps) +end + +return Checkbox diff --git a/src/Components/ColorPicker.luau b/src/Components/ColorPicker.luau new file mode 100644 index 0000000..866ae26 --- /dev/null +++ b/src/Components/ColorPicker.luau @@ -0,0 +1,421 @@ +--[=[ + @class ColorPicker + An interface for selecting a color with a Hue / Saturation box and a Value slider. + Individual RGB and HSV values can also be modified manually. + + | Dark | Light | + | - | - | + | ![Dark](/components/colorpicker/dark.png) | ![Light](/components/colorpicker/light.png) | + + This is a controlled component, which means the current color should be passed in to the + `Color` prop and a callback value to the `OnChanged` prop which gets run when the user attempts + to change the color. For example: + + ```lua + local function MyComponent() + local color, setColor = React.useState(Color3.fromHex("#008080")) + return React.createElement(StudioComponents.ColorPicker, { + Value = color, + OnChanged = setColor, + }) + end + ``` + + The default size of this component is exposed in [Constants.DefaultColorPickerSize]. + To keep all inputs accessible, it is recommended not to use a smaller size than this. + + This component is not a modal or dialog box (this should be implemented separately). +]=] + +local React = require("@pkg/@jsdotlua/react") + +local CommonProps = require("../CommonProps") +local Constants = require("../Constants") + +local Label = require("./Label") +local NumericInput = require("./NumericInput") + +local useMouseDrag = require("../Hooks/useMouseDrag") +local useTheme = require("../Hooks/useTheme") + +local function clampVector2(v: Vector2, vmin: Vector2, vmax: Vector2) + return Vector2.new(math.clamp(v.X, vmin.X, vmax.X), math.clamp(v.Y, vmin.Y, vmax.Y)) +end + +--[=[ + @within ColorPicker + @interface Props + @tag Component Props + + @field ... CommonProps + @field Color Color3 + @field OnChanged ((newColor: Color3) -> ())? +]=] + +type ColorPickerProps = CommonProps.T & { + Color: Color3, + OnChanged: ((newColor: Color3) -> ())?, +} + +local SPLIT_Y = 76 +local SPLIT_X = 50 +local PADDING = 8 + +local function generateHueKeypoints(value: number) + local keypoints = {} + local regions = 6 + for hue = 0, regions do + local offset = hue / regions + local color = Color3.fromHSV((regions - hue) / regions, 1, value) + table.insert(keypoints, ColorSequenceKeypoint.new(offset, color)) + end + return ColorSequence.new(keypoints) +end + +local noop = function() end + +local function ValPicker(props: { + HSV: { number }, + OnChanged: (hue: number, sat: number, val: number) -> (), + Disabled: boolean?, +}) + local theme = useTheme() + + local hue, sat, val = unpack(props.HSV) + local drag = useMouseDrag(function(rbx: GuiObject, input: InputObject) + local mousePos = input.Position.Y + local alpha = (mousePos - rbx.AbsolutePosition.Y) / rbx.AbsoluteSize.Y + alpha = math.clamp(alpha, 0, 1) + props.OnChanged(hue, sat, 1 - alpha) + end, { hue, sat, val, props.OnChanged } :: { unknown }) + + React.useEffect(function() + if props.Disabled and drag.isActive() then + drag.cancel() + end + end, { props.Disabled, drag.isActive() }) + + local gradientTarget = Color3.fromHSV(hue, sat, 1) + + return React.createElement("TextButton", { + Active = false, + AutoButtonColor = false, + Text = "", + Size = UDim2.new(0, 14, 1, 0), + AnchorPoint = Vector2.new(1, 0), + Position = UDim2.new(1, -6, 0, 0), + BorderColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Border), + BackgroundColor3 = Color3.fromRGB(255, 255, 255), + BackgroundTransparency = if props.Disabled then 0.75 else 0, + [React.Event.InputBegan] = if not props.Disabled then drag.onInputBegan else nil, + [React.Event.InputChanged] = if not props.Disabled then drag.onInputChanged else nil, + [React.Event.InputEnded] = if not props.Disabled then drag.onInputEnded else nil, + }, { + Gradient = React.createElement("UIGradient", { + Color = ColorSequence.new(Color3.fromRGB(0, 0, 0), gradientTarget), + Rotation = 270, + }), + Arrow = React.createElement("ImageLabel", { + AnchorPoint = Vector2.new(0, 0.5), + Size = UDim2.fromOffset(5, 9), + Position = UDim2.new(1, 1, 1 - val, 0), + BackgroundTransparency = 1, + Image = "rbxassetid://7507468017", + ImageColor3 = theme:GetColor(Enum.StudioStyleGuideColor.TitlebarText), + Visible = not props.Disabled, + }), + Cover = props.Disabled and React.createElement("Frame", { + Size = UDim2.fromScale(1, 1), + BorderSizePixel = 0, + BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.MainBackground), + BackgroundTransparency = 0.5, + ZIndex = 2, + }), + }) +end + +local function HueSatPicker(props: { + HSV: { number }, + OnChanged: (hue: number, sat: number, val: number) -> (), + Disabled: boolean?, +}) + local theme = useTheme() + + local hue, sat, val = unpack(props.HSV) + local bgVal = 220 / 255 -- used to just use val but was weird when val was low + local indicatorBackground = if bgVal > 0.4 then Color3.new(0, 0, 0) else Color3.fromRGB(200, 200, 200) + + local drag = useMouseDrag(function(rbx: GuiObject, input: InputObject) + local mousePos = Vector2.new(input.Position.X, input.Position.Y) + local alpha = (mousePos - rbx.AbsolutePosition) / rbx.AbsoluteSize + alpha = clampVector2(alpha, Vector2.zero, Vector2.one) + props.OnChanged(1 - alpha.X, 1 - alpha.Y, val) + end, { hue, sat, val, props.OnChanged } :: { unknown }) + + React.useEffect(function() + if props.Disabled and drag.isActive() then + drag.cancel() + end + end, { props.Disabled, drag.isActive() }) + + return React.createElement("TextButton", { + Size = UDim2.new(1, -30, 1, 0), + ClipsDescendants = true, + AutoButtonColor = false, + BorderColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Border), + BackgroundTransparency = if props.Disabled then 0.75 else 0, + Active = false, + Text = "", + [React.Event.InputBegan] = if not props.Disabled then drag.onInputBegan else nil, + [React.Event.InputChanged] = if not props.Disabled then drag.onInputChanged else nil, + [React.Event.InputEnded] = if not props.Disabled then drag.onInputEnded else nil, + }, { + Hue = React.createElement("Frame", { + BackgroundColor3 = Color3.fromRGB(255, 255, 255), + Size = UDim2.fromScale(1, 1), + ZIndex = 0, + }, { + Gradient = React.createElement("UIGradient", { + Color = generateHueKeypoints(bgVal), + }), + }), + Sat = React.createElement("Frame", { + BackgroundColor3 = Color3.fromRGB(255, 255, 255), + Size = UDim2.fromScale(1, 1), + ZIndex = 1, + }, { + Gradient = React.createElement("UIGradient", { + Color = ColorSequence.new(Color3.fromHSV(1, 0, bgVal)), + Transparency = NumberSequence.new(1, 0), + Rotation = 90, + }), + }), + Indicator = React.createElement("Frame", { + AnchorPoint = Vector2.new(0.5, 0.5), + Position = UDim2.new(1 - hue, 0, 1 - sat, 0), + Size = UDim2.fromOffset(20, 20), + BackgroundTransparency = 1, + ZIndex = 2, + Visible = not props.Disabled, + }, { + Vertical = React.createElement("Frame", { + Position = UDim2.fromOffset(9, 0), + Size = UDim2.new(0, 2, 1, 0), + BorderSizePixel = 0, + BackgroundColor3 = indicatorBackground, + }), + Horizontal = React.createElement("Frame", { + Position = UDim2.fromOffset(0, 9), + Size = UDim2.new(1, 0, 0, 2), + BorderSizePixel = 0, + BackgroundColor3 = indicatorBackground, + }), + }), + Cover = props.Disabled and React.createElement("Frame", { + Size = UDim2.fromScale(1, 1), + BorderSizePixel = 0, + BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.MainBackground), + BackgroundTransparency = 0.5, + ZIndex = 3, + }), + }) +end + +local function ColorControl(props: { + AnchorPoint: Vector2?, + Position: UDim2?, + Label: string, + Value: number, + Max: number, + Callback: (n: number) -> (), + Disabled: boolean?, +}) + local div = 28 + return React.createElement("Frame", { + AnchorPoint = props.AnchorPoint, + Position = props.Position, + Size = UDim2.new(0.5, -PADDING, 0, Constants.DefaultInputHeight), + BackgroundTransparency = 1, + }, { + Label = React.createElement(Label, { + Text = `{props.Label}:`, + TextXAlignment = Enum.TextXAlignment.Right, + Size = UDim2.new(0, div, 1, 0), + Disabled = props.Disabled, + }), + Input = React.createElement(NumericInput, { + AnchorPoint = Vector2.new(1, 0), + Position = UDim2.fromScale(1, 0), + Size = UDim2.new(1, -div - 5, 1, 0), + Value = props.Value, + Min = 0, + Max = props.Max, + OnValidChanged = props.Callback, + Arrows = true, + Disabled = props.Disabled, + }), + }) +end + +local function ColorControls(props: { + HSV: { number }, + RGB: { number }, + OnChangedHSV: (hue: number, sat: number, val: number) -> (), + OnChangedRGB: (red: number, green: number, blue: number) -> (), + Disabled: boolean?, +}) + local hue, sat, val = unpack(props.HSV) + local red, green, blue = unpack(props.RGB) + + return React.createElement("Frame", { + Size = UDim2.new(1, -SPLIT_X - PADDING, 1, 0), + AnchorPoint = Vector2.new(1, 0), + Position = UDim2.fromScale(1, 0), + BackgroundTransparency = 1, + }, { + Hue = React.createElement(ColorControl, { + AnchorPoint = Vector2.new(0, 0), + Label = "Hue", + Value = math.round(hue * 360), + Max = 360, + Callback = function(newHue) + props.OnChangedHSV(newHue / 360, sat, val) + end, + Disabled = props.Disabled, + }), + Sat = React.createElement(ColorControl, { + AnchorPoint = Vector2.new(0, 0.5), + Position = UDim2.fromScale(0, 0.5), + Label = "Sat", + Value = math.round(sat * 255), + Max = 255, + Callback = function(newSat) + props.OnChangedHSV(hue, newSat / 255, val) + end, + Disabled = props.Disabled, + }), + Val = React.createElement(ColorControl, { + AnchorPoint = Vector2.new(0, 1), + Position = UDim2.fromScale(0, 1), + Label = "Val", + Value = math.round(val * 255), + Max = 255, + Callback = function(newVal) + props.OnChangedHSV(hue, sat, newVal / 255) + end, + Disabled = props.Disabled, + }), + Red = React.createElement(ColorControl, { + AnchorPoint = Vector2.new(1, 0), + Position = UDim2.fromScale(1, 0), + Label = "Red", + Value = math.round(red * 255), + Max = 255, + Callback = function(newRed) + props.OnChangedRGB(newRed / 255, green, blue) + end, + Disabled = props.Disabled, + }), + Green = React.createElement(ColorControl, { + AnchorPoint = Vector2.new(1, 0.5), + Position = UDim2.fromScale(1, 0.5), + Label = "Green", + Value = math.round(green * 255), + Max = 255, + Callback = function(newGreen) + props.OnChangedRGB(red, newGreen / 255, blue) + end, + Disabled = props.Disabled, + }), + Blue = React.createElement(ColorControl, { + AnchorPoint = Vector2.new(1, 1), + Position = UDim2.fromScale(1, 1), + Label = "Blue", + Value = math.round(blue * 255), + Max = 255, + Callback = function(newBlue) + props.OnChangedRGB(red, green, newBlue / 255) + end, + Disabled = props.Disabled, + }), + }) +end + +local function ColorPicker(props: ColorPickerProps) + local theme = useTheme() + local onChanged: (color: Color3) -> () = props.OnChanged or noop + + -- avoids information loss when converting hsv -> rgb -> hsv between renders + local hsv, setHSV = React.useState({ props.Color:ToHSV() }) + React.useEffect(function() + setHSV(function(oldHSV) + if Color3.fromHSV(unpack(oldHSV)) ~= props.Color then + return { props.Color:ToHSV() } + end + return oldHSV + end) + end, { props.Color }) + + local pickerProps = { + HSV = hsv, + OnChanged = function(hue, sat, val) + setHSV({ hue, sat, val }) + onChanged(Color3.fromHSV(hue, sat, val)) + end, + Disabled = props.Disabled, + } + + local modifier = Enum.StudioStyleGuideModifier.Default + if props.Disabled then + modifier = Enum.StudioStyleGuideModifier.Disabled + end + + return React.createElement("Frame", { + AnchorPoint = props.AnchorPoint, + Position = props.Position, + Size = props.Size or Constants.DefaultColorPickerSize, + BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.MainBackground, modifier), + BorderColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Border, modifier), + BorderMode = Enum.BorderMode.Inset, + LayoutOrder = props.LayoutOrder, + ZIndex = props.ZIndex, + }, { + Padding = React.createElement("UIPadding", { + PaddingLeft = UDim.new(0, PADDING), + PaddingRight = UDim.new(0, PADDING), + PaddingTop = UDim.new(0, PADDING), + PaddingBottom = UDim.new(0, PADDING), + }), + TopArea = React.createElement("Frame", { + Size = UDim2.new(1, 0, 1, -SPLIT_Y - PADDING), + BackgroundTransparency = 1, + }, { + ValPicker = React.createElement(ValPicker, pickerProps), + HueSatPicker = React.createElement(HueSatPicker, pickerProps), + }), + BtmArea = React.createElement("Frame", { + AnchorPoint = Vector2.new(0, 1), + Size = UDim2.new(1, 0, 0, SPLIT_Y), + Position = UDim2.fromScale(0, 1), + BackgroundTransparency = 1, + }, { + Preview = React.createElement("Frame", { + Size = UDim2.new(0, SPLIT_X, 1, 0), + BackgroundTransparency = if props.Disabled then 0.75 else 0, + BackgroundColor3 = props.Color, + BorderColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Border), + }), + Controls = React.createElement(ColorControls, { + HSV = hsv, + RGB = { props.Color.R, props.Color.G, props.Color.B }, + OnChangedHSV = pickerProps.OnChanged, + OnChangedRGB = function(red, green, blue) + onChanged(Color3.new(red, green, blue)) + end, + Disabled = props.Disabled, + }), + }), + }) +end + +return ColorPicker diff --git a/src/Components/DropShadowFrame.luau b/src/Components/DropShadowFrame.luau new file mode 100644 index 0000000..df6c60e --- /dev/null +++ b/src/Components/DropShadowFrame.luau @@ -0,0 +1,108 @@ +--[=[ + @class DropShadowFrame + + A container frame equivalent in appearance to a [Background] with a + drop shadow in the lower right sides and corner. + This matches the appearance of some built-in Roblox Studio elements such as tooltips. + It is useful for providing contrast against a background. + + | Dark | Light | + | - | - | + | ![Dark](/components/dropshadowframe/dark.png) | ![Light](/components/dropshadowframe/light.png) | + + Any children passed will be parented to the container frame. For example: + + ```lua + local function MyComponent() + return React.createElement(StudioComponents.DropShadowFrame, {}, { + MyLabel = React.createElement(StudioComponents.Label, ...), + MyCheckbox = React.createElement(StudioComponents.Checkbox, ...), + }) + end + ``` +]=] + +local React = require("@pkg/@jsdotlua/react") + +local CommonProps = require("../CommonProps") +local useTheme = require("../Hooks/useTheme") + +local shadowData = { + { + Position = UDim2.fromOffset(4, 4), + Size = UDim2.new(1, 1, 1, 1), + Radius = 5, + Transparency = 0.96, + }, + { + Position = UDim2.fromOffset(1, 1), + Size = UDim2.new(1, -2, 1, -2), + Radius = 4, + Transparency = 0.88, + }, + { + Position = UDim2.fromOffset(1, 1), + Size = UDim2.new(1, -2, 1, -2), + Radius = 3, + Transparency = 0.80, + }, + { + Position = UDim2.fromOffset(1, 1), + Size = UDim2.new(1, -2, 1, -2), + Radius = 2, + Transparency = 0.77, + }, +} + +--[=[ + @within DropShadowFrame + @interface Props + @tag Component Props + + @field ... CommonProps + @field children React.ReactNode +]=] + +type DropShadowFrameProps = CommonProps.T & { + children: React.ReactNode, +} + +local function DropShadowFrame(props: DropShadowFrameProps) + local theme = useTheme() + + local shadow + for i = #shadowData, 1, -1 do + local data = shadowData[i] + shadow = React.createElement("Frame", { + Position = data.Position, + Size = data.Size, + BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.DropShadow), + BackgroundTransparency = data.Transparency, + BorderSizePixel = 0, + ZIndex = 0, + }, { + Corner = React.createElement("UICorner", { + CornerRadius = UDim.new(0, data.Radius), + }), + }, shadow) + end + + return React.createElement("Frame", { + AnchorPoint = props.AnchorPoint, + Position = props.Position, + Size = props.Size or UDim2.fromScale(1, 1), + LayoutOrder = props.LayoutOrder, + ZIndex = props.ZIndex, + BackgroundTransparency = 1, + }, { + Shadow = not props.Disabled and shadow, + Content = React.createElement("Frame", { + Size = UDim2.fromScale(1, 1), + BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.MainBackground), + BorderColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Border), + ZIndex = 1, + }, props.children), + }) +end + +return DropShadowFrame diff --git a/src/Components/Dropdown/ClearButton.luau b/src/Components/Dropdown/ClearButton.luau new file mode 100644 index 0000000..f4ac752 --- /dev/null +++ b/src/Components/Dropdown/ClearButton.luau @@ -0,0 +1,49 @@ +local React = require("@pkg/@jsdotlua/react") + +local useTheme = require("../../Hooks/useTheme") + +type ClearButtonProps = { + Size: UDim2, + Position: UDim2, + AnchorPoint: Vector2, + OnActivated: () -> (), +} + +local function ClearButton(props: ClearButtonProps) + local theme = useTheme() + local hovered, setHovered = React.useState(false) + + return React.createElement("TextButton", { + AnchorPoint = props.AnchorPoint, + Position = props.Position, + Size = props.Size, + BackgroundTransparency = 1, + ZIndex = 2, + Text = "", + [React.Event.InputBegan] = function(_, input: InputObject) + if input.UserInputType == Enum.UserInputType.MouseMovement then + setHovered(true) + end + end, + [React.Event.InputEnded] = function(_, input: InputObject) + if input.UserInputType == Enum.UserInputType.MouseMovement then + setHovered(false) + end + end, + [React.Event.Activated] = function() + props.OnActivated() + end, + }, { + Icon = React.createElement("ImageLabel", { + AnchorPoint = Vector2.new(0.5, 0.5), + Position = UDim2.fromScale(0.5, 0.5), + Size = UDim2.fromOffset(10, 10), + Image = "rbxassetid://16969027907", + ImageColor3 = theme:GetColor(Enum.StudioStyleGuideColor.SubText), + ImageTransparency = if hovered then 0 else 0.6, + BackgroundTransparency = 1, + }), + }) +end + +return ClearButton diff --git a/src/Components/Dropdown/DropdownItem.luau b/src/Components/Dropdown/DropdownItem.luau new file mode 100644 index 0000000..22d8d8c --- /dev/null +++ b/src/Components/Dropdown/DropdownItem.luau @@ -0,0 +1,94 @@ +local React = require("@pkg/@jsdotlua/react") + +local Constants = require("../../Constants") +local useTheme = require("../../Hooks/useTheme") + +local DropdownTypes = require("./Types") + +type DropdownItemProps = { + Id: string, + Text: string, + Icon: DropdownTypes.DropdownItemIcon?, + LayoutOrder: number, + Height: number, + TextInset: number, + Selected: boolean, + OnSelected: (item: string) -> (), +} + +local function DropdownItem(props: DropdownItemProps) + local theme = useTheme() + local hovered, setHovered = React.useState(false) + + local modifier = Enum.StudioStyleGuideModifier.Default + if props.Selected then + modifier = Enum.StudioStyleGuideModifier.Selected + elseif hovered then + modifier = Enum.StudioStyleGuideModifier.Hover + end + + local iconColor = Color3.fromRGB(255, 255, 255) + if props.Icon then + if props.Icon.UseThemeColor then + iconColor = theme:GetColor(Enum.StudioStyleGuideColor.MainText) + elseif props.Icon.Color then + iconColor = props.Icon.Color + end + end + + return React.createElement("Frame", { + LayoutOrder = props.LayoutOrder, + Size = UDim2.new(1, 0, 0, props.Height), + BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Item, modifier), + BorderSizePixel = 0, + }, { + Button = React.createElement("TextButton", { + Position = UDim2.fromOffset(0, 1), + Size = UDim2.new(1, 0, 1, -1), + BackgroundTransparency = 1, + AutoButtonColor = false, + Text = "", + [React.Event.InputBegan] = function(_, input) + if input.UserInputType == Enum.UserInputType.MouseMovement then + setHovered(true) + end + end, + [React.Event.InputEnded] = function(_, input) + if input.UserInputType == Enum.UserInputType.MouseMovement then + setHovered(false) + end + end, + [React.Event.Activated] = function() + props.OnSelected(props.Id) + end, + }, { + Padding = React.createElement("UIPadding", { + PaddingLeft = UDim.new(0, props.TextInset), + PaddingBottom = UDim.new(0, 2), + }), + Icon = props.Icon and React.createElement("ImageLabel", { + AnchorPoint = Vector2.new(0, 0.5), + Position = UDim2.fromScale(0, 0.5), + Size = UDim2.fromOffset(props.Icon.Size.X, props.Icon.Size.Y), + BackgroundTransparency = 1, + Image = props.Icon.Image, + ImageTransparency = props.Icon.Transparency or 0, + ImageColor3 = iconColor, + }), + Label = React.createElement("TextLabel", { + BackgroundTransparency = 1, + AnchorPoint = Vector2.new(1, 0), + Position = UDim2.fromScale(1, 0), + Size = UDim2.new(1, if props.Icon then -props.Icon.Size.X - 4 else 0, 1, 0), + Font = Constants.DefaultFont, + TextSize = Constants.DefaultTextSize, + TextColor3 = theme:GetColor(Enum.StudioStyleGuideColor.MainText, modifier), + TextXAlignment = Enum.TextXAlignment.Left, + TextTruncate = Enum.TextTruncate.AtEnd, + Text = props.Text, + }), + }), + }) +end + +return DropdownItem diff --git a/src/Components/Dropdown/Types.luau b/src/Components/Dropdown/Types.luau new file mode 100644 index 0000000..319d88c --- /dev/null +++ b/src/Components/Dropdown/Types.luau @@ -0,0 +1,42 @@ +--[=[ + @within Dropdown + @type DropdownItem string | DropdownItemDetail +]=] + +export type DropdownItem = string | DropdownItemDetail + +--[=[ + @within Dropdown + @interface DropdownItemDetail + + @field Id string + @field Text string + @field Icon DropdownItemIcon? +]=] + +export type DropdownItemDetail = { + Id: string, + Text: string, + Icon: DropdownItemIcon?, +} + +--[=[ + @within Dropdown + @interface DropdownItemIcon + + @field Image string + @field Size Vector2 + @field Transparency number? + @field Color Color3? + @field UseThemeColor boolean? +]=] + +export type DropdownItemIcon = { + Image: string, + Size: Vector2, + Transparency: number?, + Color: Color3?, + UseThemeColor: boolean?, +} + +return {} diff --git a/src/Components/Dropdown/init.luau b/src/Components/Dropdown/init.luau new file mode 100644 index 0000000..534fe53 --- /dev/null +++ b/src/Components/Dropdown/init.luau @@ -0,0 +1,385 @@ +--[=[ + @class Dropdown + + A togglable popup box containing a list of items to select a single item from. + + Clicking the top section of a dropdown opens the list. Selecting an item, clicking anywhere else, + or pressing the Escape key will close the dropdown list. The list renders above all other UI + elements under the same LayerCollector. + + | Dark | Light | + | - | - | + | ![Dark](/components/dropdown/dark.png) | ![Light](/components/dropdown/light.png) | + + By default, the dropdown list opens below the top section. However, if there is not enough + space below, and there is more space above, the dropdown list will open upwards instead. + + The height of the top row can also be customized by passing a `Size` prop. The default size of the + top row can be found in [Constants.DefaultDropdownHeight]. + + The height of the dropdown list box is determined by the `RowHeight` and `MaxVisibleRows` props. + The default height of a list row can be found in [Constants.DefaultDropdownRowHeight]. + + Dropdowns manage their own open/closed state, but otherwise are controlled components. + This means that you need to manage the current selected item by passing a value to + `SelectedItem` and a callback value to `OnItemSelected`. For example: + + ```lua + local function MyComponent() + local selected, setSelected = React.useState("Red") + local items = { "Red", "Green", "Blue" } + return React.createElement(StudioComponents.Dropdown, { + Items = items, + SelectedItem = selected, + OnItemSelected = setSelected, + }) + end + ``` + + Dropdowns do not by themselves require a value to always be selected. To explicitly allow the + selected value to be cleared by the user, set the `ClearButton` prop to `true`. + Multiple selections are not supported. + + The list of items to select from can be specified either as strings or a [DropdownItemDetail] array. + Using the detailed item format allows custom text and icons to be displayed, as seen below: + + ```lua + local function MyComponent() + local items = { + { + Id = "item-1", + Text = "First Item", + Icon = { + Image = "rbxassetid://...", + ... + }, + }, + ... + } + ... + return React.createElement(StudioComponents.Dropdown, { + Items = items, + SelectedItem = "item-1", + ... + }) + end + ``` + + When using the detailed item format, the value in `SelectedItem` and the values that + `OnItemSelected` is called with correspond to the `Id` field of an item in the `Items` array. +]=] + +local React = require("@pkg/@jsdotlua/react") +local ReactRoblox = require("@pkg/@jsdotlua/react-roblox") + +local Constants = require("../../Constants") +local useTheme = require("../../Hooks/useTheme") + +local CommonProps = require("../../CommonProps") +local ScrollFrame = require("../ScrollFrame") + +local ClearButton = require("./ClearButton") +local DropdownItem = require("./DropdownItem") +local DropdownTypes = require("./Types") + +local LEFT_TEXT_PAD = 5 +local ARROW_WIDTH = 17 +local CLEAR_BUTTON_WIDTH = 16 +local DEFAULT_MAX_ROWS = 8 +local DEFAULT_PLACEHOLDER_TEXT = "Select..." + +local mouseClickInputs = { + [Enum.UserInputType.MouseButton1] = true, + [Enum.UserInputType.MouseButton2] = true, + [Enum.UserInputType.MouseButton3] = true, +} + +type ButtonData = { + Position: Vector2, + Size: Vector2, +} + +--[=[ + @within Dropdown + @interface Props + @tag Component Props + + @field ... CommonProps + @field Items { DropdownItem } + @field OnItemSelected ((newItem: string?) -> ())? + @field SelectedItem string? + @field DefaultText string? + @field RowHeight number? + @field MaxVisibleRows number? + @field ClearButton boolean? +]=] + +type DropdownProps = CommonProps.T & { + Items: { DropdownTypes.DropdownItem }, + OnItemSelected: ((newItem: string?) -> ())?, + SelectedItem: string?, + DefaultText: string?, + RowHeight: number?, + MaxVisibleRows: number?, + ClearButton: boolean?, +} + +local function Dropdown(props: DropdownProps) + local theme = useTheme() + + local onItemSelected: (string?) -> () = props.OnItemSelected or function() end + + local opened, setOpened = React.useState(false) + local hovered, setHovered = React.useState(false) + + local window: LayerCollector?, setWindow = React.useState(nil :: LayerCollector?) + local buttonRef = React.useRef(nil :: Frame?) + + local buttonPosBinding, setButtonPosBinding = React.useBinding(Vector2.zero) + local buttonSizeBinding, setButtonSizeBinding = React.useBinding(Vector2.zero) + local buttonDataBinding = React.joinBindings({ + Position = buttonPosBinding, + Size = buttonSizeBinding, + }) :: React.Binding + + React.useEffect(function() + local button = buttonRef.current + local connections = {} + if button ~= nil then + connections[1] = button:GetPropertyChangedSignal("AbsolutePosition"):Connect(function() + setButtonPosBinding(button.AbsolutePosition) + end) + setButtonPosBinding(button.AbsolutePosition) + connections[2] = button:GetPropertyChangedSignal("AbsoluteSize"):Connect(function() + setButtonSizeBinding(button.AbsoluteSize) + end) + setButtonSizeBinding(button.AbsoluteSize) + connections[4] = button.AncestryChanged:Connect(function() + setWindow(button:FindFirstAncestorWhichIsA("LayerCollector")) + end) + setWindow(button:FindFirstAncestorWhichIsA("LayerCollector")) + end + return function() + for _, connection in connections do + connection:Disconnect() + end + end + end, {}) + + React.useEffect(function() + local connection + if window ~= nil and window:IsA("PluginGui") then + connection = window.WindowFocusReleased:Connect(function() + setOpened(false) + end) + end + return function() + if connection then + connection:Disconnect() + end + end + end, { window }) + + React.useEffect(function() + if opened and props.Disabled then + setOpened(false) + end + end, { props.Disabled, opened }) + + local modifier = Enum.StudioStyleGuideModifier.Default + if props.Disabled then + modifier = Enum.StudioStyleGuideModifier.Disabled + elseif hovered then + modifier = Enum.StudioStyleGuideModifier.Hover + end + + local backgroundStyle = Enum.StudioStyleGuideColor.MainBackground + if (hovered or opened) and not props.Disabled then + backgroundStyle = Enum.StudioStyleGuideColor.InputFieldBackground + end + + local rowHeight = props.RowHeight or Constants.DefaultDropdownRowHeight + + local overlay + if window and opened and not props.Disabled then + local items: { [string]: React.ReactNode } = {} + for i, item in props.Items do + local id, text, icon + if type(item) == "string" then + id = item + text = item + else + id = item.Id + text = item.Text + icon = item.Icon + end + items[id] = React.createElement(DropdownItem, { + LayoutOrder = i, + Id = id, + Text = text, + Icon = icon, + TextInset = LEFT_TEXT_PAD - 1, + Height = rowHeight, + Selected = id == props.SelectedItem, + OnSelected = function(newItem) + setOpened(false) + onItemSelected(newItem) + end, + }) + end + + local maxVisibleRows = props.MaxVisibleRows or DEFAULT_MAX_ROWS + local numVisibleRows = math.min(#props.Items, maxVisibleRows) + local listHeight = numVisibleRows * rowHeight + + local dropDirection = "Down" + local buttonDataNow = buttonDataBinding:getValue() + local spaceBelow = window.AbsoluteSize.Y - (buttonDataNow.Position.Y + buttonDataNow.Size.Y) + local spaceAbove = buttonDataNow.Position.Y + if spaceBelow < listHeight and spaceAbove > spaceBelow then + dropDirection = "Up" + end + + local function onOverlayInputBegan(_, input: InputObject) + if mouseClickInputs[input.UserInputType] then + local buttonData = buttonDataBinding:getValue() + local areaSize = Vector2.new(buttonData.Size.X, buttonData.Size.Y + listHeight) + local areaPos = buttonData.Position + if dropDirection == "Up" then + areaPos -= Vector2.new(0, listHeight) + end + local offset = Vector2.new(input.Position.X, input.Position.Y) - areaPos + if offset.X < 0 or offset.X > areaSize.X or offset.Y < 0 or offset.Y > areaSize.Y then + -- only clicks outside the dropdown will close it + -- this enables e.g. clicking dropdown list scrollbar buttons + setOpened(false) + end + elseif input.UserInputType == Enum.UserInputType.Keyboard then + if input.KeyCode == Enum.KeyCode.Escape then + setOpened(false) + end + end + end + + overlay = React.createElement("Frame", { + Size = UDim2.fromScale(1, 1), + BackgroundTransparency = 1, + [React.Event.InputBegan] = onOverlayInputBegan, + }, { + List = React.createElement("Frame", { + AnchorPoint = Vector2.new(0, if dropDirection == "Down" then 0 else 1), + Position = buttonDataBinding:map(function(data: ButtonData) + local px = math.round(data.Position.X) + local py = math.round(data.Position.Y) + if dropDirection == "Down" then + py += math.round(data.Size.Y) + end + return UDim2.fromOffset(px, py) + end), + Size = buttonSizeBinding:map(function(size: Vector2) + return UDim2.fromOffset(math.round(size.X), listHeight) + end), + BorderColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Border), + }, { + Scroller = React.createElement(ScrollFrame, { + Size = UDim2.fromScale(1, 1), + Layout = { + ClassName = "UIListLayout", + SortOrder = Enum.SortOrder.LayoutOrder, + }, + }, items), + }), + }) + end + + local selectedText = props.DefaultText or DEFAULT_PLACEHOLDER_TEXT + for _, item in props.Items do + if type(item) == "string" then + if item == props.SelectedItem then + selectedText = item + break + end + else + if item.Id == props.SelectedItem then + selectedText = item.Text + break + end + end + end + + local showClearButton = false + if props.ClearButton and props.SelectedItem and props.Disabled ~= true then + showClearButton = true + end + + return React.createElement("Frame", { + AnchorPoint = props.AnchorPoint, + Position = props.Position, + Size = props.Size or UDim2.new(1, 0, 0, Constants.DefaultDropdownHeight), + LayoutOrder = props.LayoutOrder, + ZIndex = props.ZIndex, + BackgroundColor3 = theme:GetColor(backgroundStyle, modifier), + BorderColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Border, modifier), + [React.Event.InputBegan] = function(_, input: InputObject) + if input.UserInputType == Enum.UserInputType.MouseMovement then + setHovered(true) + elseif input.UserInputType == Enum.UserInputType.MouseButton1 then + if not props.Disabled then + setOpened(not opened) + end + end + end, + [React.Event.InputEnded] = function(_, input: InputObject) + if input.UserInputType == Enum.UserInputType.MouseMovement then + setHovered(false) + end + end, + ref = buttonRef, + }, { + Overlay = window and overlay and ReactRoblox.createPortal(overlay, window), + Label = React.createElement("TextLabel", { + Position = UDim2.fromOffset(5, 0), + Size = UDim2.new( + 1, + -LEFT_TEXT_PAD - ARROW_WIDTH - 2 - (if showClearButton then CLEAR_BUTTON_WIDTH + 2 else 0), + 1, + -1 + ), + Text = selectedText, + TextTransparency = if props.SelectedItem then 0 else 0.4, + Font = Constants.DefaultFont, + TextSize = Constants.DefaultTextSize, + TextColor3 = theme:GetColor(Enum.StudioStyleGuideColor.MainText, modifier), + TextXAlignment = Enum.TextXAlignment.Left, + TextTruncate = Enum.TextTruncate.AtEnd, + BackgroundTransparency = 1, + }), + ClearButton = showClearButton and React.createElement(ClearButton, { + AnchorPoint = Vector2.new(1, 0), + Position = UDim2.new(1, -ARROW_WIDTH - 2, 0, 0), + Size = UDim2.new(0, CLEAR_BUTTON_WIDTH, 1, 0), + OnActivated = function() + onItemSelected(nil) + setOpened(false) + end, + }), + ArrowContainer = React.createElement("Frame", { + AnchorPoint = Vector2.new(1, 0), + Position = UDim2.fromScale(1, 0), + Size = UDim2.new(0, ARROW_WIDTH, 1, 0), + BackgroundTransparency = 1, + ZIndex = 2, + }, { + Arrow = React.createElement("ImageLabel", { + Image = "rbxassetid://7260137654", + AnchorPoint = Vector2.new(0.5, 0.5), + Position = UDim2.fromScale(0.5, 0.5), + Size = UDim2.fromOffset(8, 4), + BackgroundTransparency = 1, + ImageColor3 = theme:GetColor(Enum.StudioStyleGuideColor.TitlebarText, modifier), + }), + }), + }) +end + +return Dropdown diff --git a/src/Components/Foundation/BaseButton.luau b/src/Components/Foundation/BaseButton.luau new file mode 100644 index 0000000..7653113 --- /dev/null +++ b/src/Components/Foundation/BaseButton.luau @@ -0,0 +1,133 @@ +local React = require("@pkg/@jsdotlua/react") + +local CommonProps = require("../../CommonProps") +local Constants = require("../../Constants") + +local getTextSize = require("../../getTextSize") +local useTheme = require("../../Hooks/useTheme") + +local PADDING_X = 8 +local PADDING_Y = 4 +local DEFAULT_HEIGHT = Constants.DefaultButtonHeight + +export type BaseButtonConsumerProps = CommonProps.T & { + AutomaticSize: Enum.AutomaticSize?, + OnActivated: (() -> ())?, + Text: string?, + Icon: { + Image: string, + Size: Vector2, + Transparency: number?, + Color: Color3?, + UseThemeColor: boolean?, + Alignment: Enum.HorizontalAlignment?, + }?, +} + +export type BaseButtonProps = BaseButtonConsumerProps & { + BackgroundColorStyle: Enum.StudioStyleGuideColor, + BorderColorStyle: Enum.StudioStyleGuideColor, + TextColorStyle: Enum.StudioStyleGuideColor, +} + +local function BaseButton(props: BaseButtonProps) + local theme = useTheme() + + local textSize = if props.Text then getTextSize(props.Text) else Vector2.zero + local iconSize = if props.Icon then props.Icon.Size else Vector2.zero + + local contentWidth = textSize.X + iconSize.X + if props.Text and props.Icon then + contentWidth += PADDING_X + end + + local contentHeight = textSize.Y + if props.Icon then + contentHeight = math.max(contentHeight, iconSize.Y) + end + + local hovered, setHovered = React.useState(false) + local pressed, setPressed = React.useState(false) + local modifier = Enum.StudioStyleGuideModifier.Default + if props.Disabled then + modifier = Enum.StudioStyleGuideModifier.Disabled + elseif pressed and hovered then + modifier = Enum.StudioStyleGuideModifier.Pressed + elseif hovered then + modifier = Enum.StudioStyleGuideModifier.Hover + end + + local backColor = theme:GetColor(props.BackgroundColorStyle, modifier) + local borderColor3 = theme:GetColor(props.BorderColorStyle, modifier) + local textColor = theme:GetColor(props.TextColorStyle, modifier) + + local size = props.Size or UDim2.new(1, 0, 0, DEFAULT_HEIGHT) + local autoSize = props.AutomaticSize + if autoSize == Enum.AutomaticSize.X or autoSize == Enum.AutomaticSize.XY then + size = UDim2.new(UDim.new(0, contentWidth + PADDING_X * 2), size.Height) + end + if autoSize == Enum.AutomaticSize.Y or autoSize == Enum.AutomaticSize.XY then + size = UDim2.new(size.Width, UDim.new(0, math.max(DEFAULT_HEIGHT, contentHeight + PADDING_Y * 2))) + end + + return React.createElement("TextButton", { + AutoButtonColor = false, + AnchorPoint = props.AnchorPoint, + Position = props.Position, + Size = size, + LayoutOrder = props.LayoutOrder, + ZIndex = props.ZIndex, + Text = "", + BackgroundColor3 = backColor, + BorderColor3 = borderColor3, + [React.Event.InputBegan] = function(_, input) + if input.UserInputType == Enum.UserInputType.MouseMovement then + setHovered(true) + elseif input.UserInputType == Enum.UserInputType.MouseButton1 then + setPressed(true) + end + end, + [React.Event.InputEnded] = function(_, input) + if input.UserInputType == Enum.UserInputType.MouseMovement then + setHovered(false) + elseif input.UserInputType == Enum.UserInputType.MouseButton1 then + setPressed(false) + end + end, + [React.Event.Activated] = function() + if not props.Disabled and props.OnActivated then + props.OnActivated() + end + end, + }, { + Layout = React.createElement("UIListLayout", { + Padding = UDim.new(0, PADDING_X), + SortOrder = Enum.SortOrder.LayoutOrder, + FillDirection = Enum.FillDirection.Horizontal, + HorizontalAlignment = Enum.HorizontalAlignment.Center, + VerticalAlignment = Enum.VerticalAlignment.Center, + }), + Icon = props.Icon and React.createElement("ImageLabel", { + Image = props.Icon.Image, + Size = UDim2.fromOffset(props.Icon.Size.X, props.Icon.Size.Y), + LayoutOrder = if props.Icon.Alignment == Enum.HorizontalAlignment.Right then 3 else 1, + BackgroundTransparency = 1, + ImageColor3 = if props.Icon.Color + then props.Icon.Color + elseif props.Icon.UseThemeColor then textColor + else nil, + ImageTransparency = 1 - (1 - (props.Icon.Transparency or 0)) * (1 - if props.Disabled then 0.2 else 0), + }), + Label = props.Text and React.createElement("TextLabel", { + TextColor3 = textColor, + Font = Constants.DefaultFont, + TextSize = Constants.DefaultTextSize, + Text = props.Text, + Size = UDim2.new(0, textSize.X, 1, 0), + BackgroundTransparency = 1, + LayoutOrder = 2, + }), + }) +end + +return BaseButton diff --git a/src/Components/Foundation/BaseLabelledToggle.luau b/src/Components/Foundation/BaseLabelledToggle.luau new file mode 100644 index 0000000..f4d17cd --- /dev/null +++ b/src/Components/Foundation/BaseLabelledToggle.luau @@ -0,0 +1,114 @@ +local React = require("@pkg/@jsdotlua/react") + +local CommonProps = require("../../CommonProps") +local Constants = require("../../Constants") + +local getTextSize = require("../../getTextSize") +local useTheme = require("../../Hooks/useTheme") + +local DEFAULT_HEIGHT = Constants.DefaultToggleHeight +local BOX_SIZE = 16 +local INNER_PADDING = 6 + +export type BaseLabelledToggleConsumerProps = CommonProps.T & { + ContentAlignment: Enum.HorizontalAlignment?, + ButtonAlignment: Enum.HorizontalAlignment?, + OnChanged: (() -> ())?, + Label: string?, +} + +export type BaseLabelledToggleProps = BaseLabelledToggleConsumerProps & { + RenderButton: React.FC<{ Hovered: boolean }>?, +} + +local function BaseLabelledToggle(props: BaseLabelledToggleProps) + local theme = useTheme() + local hovered, setHovered = React.useState(false) + + local mainModifier = Enum.StudioStyleGuideModifier.Default + if props.Disabled then + mainModifier = Enum.StudioStyleGuideModifier.Disabled + elseif hovered then + mainModifier = Enum.StudioStyleGuideModifier.Hover + end + + local contentAlignment = props.ContentAlignment or Enum.HorizontalAlignment.Left + local buttonAlignment = props.ButtonAlignment or Enum.HorizontalAlignment.Left + + local textWidth = if props.Label then getTextSize(props.Label).X else 0 + local textAlignment = Enum.TextXAlignment.Left + local buttonOrder = 1 + local labelOrder = 2 + if buttonAlignment == Enum.HorizontalAlignment.Right then + buttonOrder = 2 + labelOrder = 1 + textAlignment = Enum.TextXAlignment.Right + end + + local content = nil + if props.RenderButton then + content = React.createElement(props.RenderButton, { + Hovered = hovered, + }) + end + + return React.createElement("TextButton", { + Size = props.Size or UDim2.new(1, 0, 0, DEFAULT_HEIGHT), + Position = props.Position, + AnchorPoint = props.AnchorPoint, + LayoutOrder = props.LayoutOrder, + ZIndex = props.ZIndex, + BackgroundTransparency = 1, + Text = "", + [React.Event.InputBegan] = function(_, input) + if input.UserInputType == Enum.UserInputType.MouseMovement then + setHovered(true) + end + end, + [React.Event.InputEnded] = function(_, input) + if input.UserInputType == Enum.UserInputType.MouseMovement then + setHovered(false) + end + end, + [React.Event.Activated] = function() + if not props.Disabled and props.OnChanged then + props.OnChanged() + end + end, + }, { + Layout = React.createElement("UIListLayout", { + HorizontalAlignment = contentAlignment, + VerticalAlignment = Enum.VerticalAlignment.Center, + FillDirection = Enum.FillDirection.Horizontal, + SortOrder = Enum.SortOrder.LayoutOrder, + Padding = UDim.new(0, INNER_PADDING), + }), + Button = React.createElement("Frame", { + BackgroundTransparency = 1, + Size = UDim2.fromOffset(BOX_SIZE, BOX_SIZE), + LayoutOrder = buttonOrder, + }, { + Content = content, + }), + Label = props.Label and React.createElement("TextLabel", { + BackgroundTransparency = 1, + Size = UDim2.new(1, -BOX_SIZE - INNER_PADDING, 1, 0), + TextXAlignment = textAlignment, + TextTruncate = Enum.TextTruncate.AtEnd, + Text = props.Label, + Font = Constants.DefaultFont, + TextSize = Constants.DefaultTextSize, + TextColor3 = theme:GetColor(Enum.StudioStyleGuideColor.MainText, mainModifier), + LayoutOrder = labelOrder, + }, { + Constraint = React.createElement("UISizeConstraint", { + MaxSize = Vector2.new(textWidth, math.huge), + }), + Pad = React.createElement("UIPadding", { + PaddingBottom = UDim.new(0, 1), + }), + }), + }) +end + +return BaseLabelledToggle diff --git a/src/Components/Foundation/BaseTextInput.luau b/src/Components/Foundation/BaseTextInput.luau new file mode 100644 index 0000000..6942cda --- /dev/null +++ b/src/Components/Foundation/BaseTextInput.luau @@ -0,0 +1,191 @@ +local React = require("@pkg/@jsdotlua/react") + +local CommonProps = require("../../CommonProps") +local Constants = require("../../Constants") + +local getTextSize = require("../../getTextSize") +local useTheme = require("../../Hooks/useTheme") + +local PLACEHOLDER_TEXT_COLOR = Color3.fromRGB(102, 102, 102) +local EDGE_PADDING_PX = 5 +local DEFAULT_HEIGHT = Constants.DefaultInputHeight + +local TEXT_SIZE = Constants.DefaultTextSize +local FONT = Constants.DefaultFont + +local function joinDictionaries(dict0, dict1) + local joined = table.clone(dict0) + for k, v in dict1 do + joined[k] = v + end + return joined +end + +export type BaseTextInputConsumerProps = CommonProps.T & { + PlaceholderText: string?, + ClearTextOnFocus: boolean?, + OnFocused: (() -> ())?, + OnFocusLost: ((text: string, enterPressed: boolean, input: InputObject) -> ())?, + children: React.ReactNode, +} + +export type BaseTextInputProps = BaseTextInputConsumerProps & { + Text: string, + OnChanged: (newText: string) -> (), + RightPaddingExtra: number?, +} + +local function BaseTextInput(props: BaseTextInputProps) + local theme = useTheme() + local hovered, setHovered = React.useState(false) + local focused, setFocused = React.useState(false) + local disabled = props.Disabled == true + + local predictNextCursor = React.useRef(-1) :: { current: number } + local lastCursor = React.useRef(-1) :: { current: number } + + local mainModifier = Enum.StudioStyleGuideModifier.Default + local borderModifier = Enum.StudioStyleGuideModifier.Default + if disabled then + mainModifier = Enum.StudioStyleGuideModifier.Disabled + borderModifier = Enum.StudioStyleGuideModifier.Disabled + elseif focused then + borderModifier = Enum.StudioStyleGuideModifier.Selected + elseif hovered then + borderModifier = Enum.StudioStyleGuideModifier.Hover + end + + local cursor, setCursor = React.useState(-1) + local containerSize, setContainerSize = React.useState(Vector2.zero) + local innerOffset = React.useRef(0) :: { current: number } + + local fullTextWidth = getTextSize(props.Text).X + local textFieldSize = UDim2.fromScale(1, 1) + + if not disabled then + local min = EDGE_PADDING_PX + local max = containerSize.X - EDGE_PADDING_PX + local textUpToCursor = string.sub(props.Text, 1, cursor - 1) + local offset = getTextSize(textUpToCursor).X + EDGE_PADDING_PX + local innerArea = max - min + local fullOffset = offset + innerOffset.current + if fullTextWidth <= innerArea or not focused then + innerOffset.current = 0 + else + if fullOffset < min then + innerOffset.current += min - fullOffset + elseif fullOffset > max then + innerOffset.current -= fullOffset - max + end + innerOffset.current = math.max(innerOffset.current, innerArea - fullTextWidth) + end + else + innerOffset.current = 0 + end + + if focused then + local textFieldWidth = math.max(containerSize.X, fullTextWidth + EDGE_PADDING_PX * 2) + textFieldSize = UDim2.new(0, textFieldWidth, 1, 0) + end + + local textFieldProps = { + Size = textFieldSize, + Position = UDim2.fromOffset(innerOffset.current, 0), + BackgroundTransparency = 1, + Font = FONT, + Text = props.Text, + TextSize = TEXT_SIZE, + TextColor3 = theme:GetColor(Enum.StudioStyleGuideColor.MainText, mainModifier), + TextXAlignment = Enum.TextXAlignment.Left, + TextTruncate = if focused then Enum.TextTruncate.None else Enum.TextTruncate.AtEnd, + [React.Event.InputBegan] = function(_, input: InputObject) + if input.UserInputType == Enum.UserInputType.MouseMovement then + setHovered(true) + end + end, + [React.Event.InputEnded] = function(_, input: InputObject) + if input.UserInputType == Enum.UserInputType.MouseMovement then + setHovered(false) + end + end, + children = { + Padding = React.createElement("UIPadding", { + PaddingLeft = UDim.new(0, EDGE_PADDING_PX), + PaddingRight = UDim.new(0, EDGE_PADDING_PX), + }), + }, + } + + local textField + if disabled then + textField = React.createElement("TextLabel", textFieldProps) + else + textField = React.createElement( + "TextBox", + joinDictionaries(textFieldProps, { + PlaceholderText = props.PlaceholderText, + PlaceholderColor3 = PLACEHOLDER_TEXT_COLOR, + ClearTextOnFocus = props.ClearTextOnFocus, + MultiLine = false, + [React.Change.CursorPosition] = function(rbx: TextBox) + -- cursor position changed fires before text changed, so we defer it until after; + -- this enables us to use the pre-text-changed cursor position to revert to + task.defer(function() + lastCursor.current = rbx.CursorPosition + end) + setCursor(rbx.CursorPosition) + end, + [React.Change.Text] = function(rbx: TextBox) + local newText = rbx.Text + if newText ~= props.Text then + predictNextCursor.current = rbx.CursorPosition + rbx.Text = props.Text + rbx.CursorPosition = math.max(1, lastCursor.current) + props.OnChanged((string.gsub(newText, "[\n\r]", ""))) + elseif focused then + rbx.CursorPosition = math.max(1, predictNextCursor.current) + end + end, + [React.Event.Focused] = function() + setFocused(true) + if props.OnFocused then + props.OnFocused() + end + end, + [React.Event.FocusLost] = function(rbx: TextBox, enterPressed: boolean, input: InputObject) + setFocused(false) + if props.OnFocusLost then + props.OnFocusLost(rbx.Text, enterPressed, input) + end + end :: () -> (), + }) + ) + end + + local rightPaddingExtra = props.RightPaddingExtra or 0 + + return React.createElement("Frame", { + AnchorPoint = props.AnchorPoint, + Position = props.Position, + Size = props.Size or UDim2.new(1, 0, 0, DEFAULT_HEIGHT), + LayoutOrder = props.LayoutOrder, + ZIndex = props.ZIndex, + BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.InputFieldBackground, mainModifier), + BorderColor3 = theme:GetColor(Enum.StudioStyleGuideColor.InputFieldBorder, borderModifier), + BorderMode = Enum.BorderMode.Inset, + [React.Change.AbsoluteSize] = function(rbx: Frame) + setContainerSize(rbx.AbsoluteSize - Vector2.new(rightPaddingExtra, 0)) + end, + }, { + Clipping = React.createElement("Frame", { + Size = UDim2.new(1, -rightPaddingExtra, 1, 0), + BackgroundTransparency = 1, + ClipsDescendants = true, + ZIndex = 0, + }, { + TextField = textField, + }), + }, props.children) +end + +return BaseTextInput diff --git a/src/Components/Label.luau b/src/Components/Label.luau new file mode 100644 index 0000000..b8aea26 --- /dev/null +++ b/src/Components/Label.luau @@ -0,0 +1,114 @@ +--[=[ + @class Label + + A basic text label with default styling to match built-in labels as closely as possible. + + | Dark | Light | + | - | - | + | ![Dark](/components/label/dark.png) | ![Light](/components/label/light.png) | + + By default, text color matches the current theme's MainText color, which is the color + used in the Explorer and Properties widgets as well as most other places. It can be overriden + in two ways: + 1. Passing a [StudioStyleGuideColor](https://create.roblox.com/docs/reference/engine/enums/StudioStyleGuideColor) + to the `TextColorStyle` prop. This is the preferred way to recolor text + because it will use the correct version of the color for the user's current selected theme. + 2. Passing a [Color3] value to the `TextColor3` prop. This is useful when a color is not represented + by any StudioStyleGuideColor or should remain constant regardless of theme. + + Example of creating an error message label: + + ```lua + local function MyComponent() + return React.createElement(StudioComponents.Label, { + Text = "Please enter at least 5 characters!", + TextColorStyle = Enum.StudioStyleGuideColor.ErrorText, + }) + end + ``` + + Plugins like [Theme Color Shower](https://create.roblox.com/store/asset/3115567199/Theme-Color-Shower) + are useful for finding a StudioStyleGuideColor to use. + + This component will parent any children passed to it to the underlying TextLabel instance. + This is useful for things like adding extra padding around the text using a nested UIPadding, + or adding a UIStroke / UIGradient. + + Labels use [Constants.DefaultFont] for Font and [Constants.DefaultTextSize] for TextSize. This + cannot currently be overriden via props. +]=] + +local React = require("@pkg/@jsdotlua/react") + +local CommonProps = require("../CommonProps") +local Constants = require("../Constants") +local useTheme = require("../Hooks/useTheme") + +--[=[ + @within Label + @interface Props + @tag Component Props + + @field ... CommonProps + @field Text string + @field TextWrapped boolean? + @field TextXAlignment Enum.TextXAlignment? + @field TextYAlignment Enum.TextYAlignment? + @field TextTruncate Enum.TextTruncate? + @field TextTransparency number? + @field TextColor3 Color3? + @field RichText boolean? + @field MaxVisibleGraphemes number? + @field TextColorStyle Enum.StudioStyleGuideColor? + @field children React.ReactNode +]=] + +type LabelProps = CommonProps.T & { + Text: string, + TextWrapped: boolean?, + TextXAlignment: Enum.TextXAlignment?, + TextYAlignment: Enum.TextYAlignment?, + TextTruncate: Enum.TextTruncate?, + TextTransparency: number?, + TextColor3: Color3?, + RichText: boolean?, + MaxVisibleGraphemes: number?, + TextColorStyle: Enum.StudioStyleGuideColor?, + children: React.ReactNode, +} + +local function Label(props: LabelProps) + local theme = useTheme() + local modifier = Enum.StudioStyleGuideModifier.Default + if props.Disabled then + modifier = Enum.StudioStyleGuideModifier.Disabled + end + + local style = props.TextColorStyle or Enum.StudioStyleGuideColor.MainText + local color = theme:GetColor(style, modifier) + if props.TextColor3 ~= nil then + color = props.TextColor3 + end + + return React.createElement("TextLabel", { + AnchorPoint = props.AnchorPoint, + Position = props.Position, + Size = props.Size or UDim2.fromScale(1, 1), + LayoutOrder = props.LayoutOrder, + ZIndex = props.ZIndex, + Text = props.Text, + BackgroundTransparency = 1, + Font = Constants.DefaultFont, + TextSize = Constants.DefaultTextSize, + TextColor3 = color, + TextTransparency = props.TextTransparency, + TextXAlignment = props.TextXAlignment, + TextYAlignment = props.TextYAlignment, + TextTruncate = props.TextTruncate, + TextWrapped = props.TextWrapped, + RichText = props.RichText, + MaxVisibleGraphemes = props.MaxVisibleGraphemes, + }, props.children) +end + +return Label diff --git a/src/Components/LoadingDots.luau b/src/Components/LoadingDots.luau new file mode 100644 index 0000000..bb0f6ce --- /dev/null +++ b/src/Components/LoadingDots.luau @@ -0,0 +1,107 @@ +--[=[ + @class LoadingDots + + A basic animated loading indicator. This matches similar indicators used in various places + around Studio. This should be used for short processes where the user does not need to see + information about how complete the loading is. For longer or more detailed loading processes, + consider using a [ProgressBar]. + + | Dark | Light | + | - | - | + | ![Dark](/components/loadingdots/dark.gif) | ![Light](/components/loadingdots/light.gif) | + + Example of usage: + + ```lua + local function MyComponent() + return React.createElement(StudioComponents.LoadingDots, {}) + end + ``` +]=] + +local RunService = game:GetService("RunService") + +local React = require("@pkg/@jsdotlua/react") + +local CommonProps = require("../CommonProps") +local useTheme = require("../Hooks/useTheme") + +--[=[ + @within LoadingDots + @interface Props + @tag Component Props + + @field ... CommonProps +]=] + +type LoadingDotsProps = CommonProps.T + +local function Dot(props: { + LayoutOrder: number, + Transparency: React.Binding, + Disabled: boolean?, +}) + local theme = useTheme() + + return React.createElement("Frame", { + LayoutOrder = props.LayoutOrder, + Size = UDim2.fromOffset(10, 10), + BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.ButtonText), + BorderColor3 = theme:GetColor(Enum.StudioStyleGuideColor.ButtonBorder), + BackgroundTransparency = if props.Disabled then 0.75 else props.Transparency, + }) +end + +local function LoadingDots(props: LoadingDotsProps) + local clockBinding, setClockBinding = React.useBinding(os.clock()) + React.useEffect(function() + local connection = RunService.Heartbeat:Connect(function() + setClockBinding(os.clock()) + end) + return function() + return connection:Disconnect() + end + end, {}) + + local alphaBinding = clockBinding:map(function(clock: number) + return clock % 1 + end) + + return React.createElement("Frame", { + AnchorPoint = props.AnchorPoint, + Position = props.Position, + Size = props.Size or UDim2.fromScale(1, 1), + LayoutOrder = props.LayoutOrder, + ZIndex = props.ZIndex, + BackgroundTransparency = 1, + }, { + Layout = React.createElement("UIListLayout", { + SortOrder = Enum.SortOrder.LayoutOrder, + FillDirection = Enum.FillDirection.Horizontal, + HorizontalAlignment = Enum.HorizontalAlignment.Center, + VerticalAlignment = Enum.VerticalAlignment.Center, + Padding = UDim.new(0, 8), + }), + Dot0 = React.createElement(Dot, { + LayoutOrder = 0, + Transparency = alphaBinding, + Disabled = props.Disabled, + }), + Dot1 = React.createElement(Dot, { + LayoutOrder = 1, + Transparency = alphaBinding:map(function(alpha: number) + return (alpha - 0.2) % 1 + end), + Disabled = props.Disabled, + }), + Dot2 = React.createElement(Dot, { + LayoutOrder = 2, + Transparency = alphaBinding:map(function(alpha: number) + return (alpha - 0.4) % 1 + end), + Disabled = props.Disabled, + }), + }) +end + +return LoadingDots diff --git a/src/Components/MainButton.luau b/src/Components/MainButton.luau new file mode 100644 index 0000000..07445c6 --- /dev/null +++ b/src/Components/MainButton.luau @@ -0,0 +1,51 @@ +--[=[ + @class MainButton + + A variant of a [Button](#Button) used to indicate a primary action, for example an 'OK/Accept' button + in a modal. + + | Dark | Light | + | - | - | + | ![Dark](/components/mainbutton/dark.png) | ![Light](/components/mainbutton/light.png) | + + See the docs for [Button](#Button) for information about customization and usage. +]=] + +local React = require("@pkg/@jsdotlua/react") + +local BaseButton = require("./Foundation/BaseButton") + +--[=[ + @within MainButton + @interface IconProps + + @field Image string + @field Size Vector2 + @field Transparency number? + @field Color Color3? + @field UseThemeColor boolean? + @field Alignment HorizontalAlignment? +]=] + +--[=[ + @within MainButton + @interface Props + @tag Component Props + + @field ... CommonProps + @field AutomaticSize AutomaticSize? + @field OnActivated (() -> ())? + @field Text string? + @field Icon IconProps? +]=] + +local function MainButton(props: BaseButton.BaseButtonConsumerProps) + local merged = table.clone(props) :: BaseButton.BaseButtonProps + merged.BackgroundColorStyle = Enum.StudioStyleGuideColor.DialogMainButton + merged.BorderColorStyle = Enum.StudioStyleGuideColor.DialogButtonBorder + merged.TextColorStyle = Enum.StudioStyleGuideColor.DialogMainButtonText + + return React.createElement(BaseButton, merged) +end + +return MainButton diff --git a/src/Components/NumberSequencePicker/AxisLabel.luau b/src/Components/NumberSequencePicker/AxisLabel.luau new file mode 100644 index 0000000..4f93b96 --- /dev/null +++ b/src/Components/NumberSequencePicker/AxisLabel.luau @@ -0,0 +1,30 @@ +local React = require("@pkg/@jsdotlua/react") + +local Constants = require("../../Constants") +local useTheme = require("../../Hooks/useTheme") + +local function AxisLabel(props: { + AnchorPoint: Vector2?, + Position: UDim2?, + TextXAlignment: Enum.TextXAlignment?, + Value: number, + Disabled: boolean?, +}) + local theme = useTheme() + + return React.createElement("TextLabel", { + AnchorPoint = props.AnchorPoint, + Position = props.Position, + Size = UDim2.fromOffset(14, 14), + BackgroundTransparency = 1, + Text = tostring(props.Value), + Font = Constants.DefaultFont, + TextSize = Constants.DefaultTextSize, + TextColor3 = theme:GetColor(Enum.StudioStyleGuideColor.DimmedText), + TextTransparency = if props.Disabled then 0.5 else 0, + TextXAlignment = props.TextXAlignment, + ZIndex = -1, + }) +end + +return AxisLabel diff --git a/src/Components/NumberSequencePicker/Constants.luau b/src/Components/NumberSequencePicker/Constants.luau new file mode 100644 index 0000000..138984f --- /dev/null +++ b/src/Components/NumberSequencePicker/Constants.luau @@ -0,0 +1,5 @@ +return { + EnvelopeTransparency = 0.65, + EnvelopeColorStyle = Enum.StudioStyleGuideColor.DialogMainButton, + EnvelopeHandleHeight = 16, +} diff --git a/src/Components/NumberSequencePicker/DashedLine.luau b/src/Components/NumberSequencePicker/DashedLine.luau new file mode 100644 index 0000000..ce4e14d --- /dev/null +++ b/src/Components/NumberSequencePicker/DashedLine.luau @@ -0,0 +1,39 @@ +local React = require("@pkg/@jsdotlua/react") + +local useTheme = require("../../Hooks/useTheme") + +local TEX_HORIZONTAL = "rbxassetid://15431624045" +local TEX_VERTICAL = "rbxassetid://15431692101" + +local function DashedLine(props: { + AnchorPoint: Vector2?, + Position: UDim2?, + Size: UDim2, + Direction: Enum.FillDirection, + Transparency: number?, + Disabled: boolean?, +}) + local theme = useTheme() + local horizontal = props.Direction == Enum.FillDirection.Horizontal + + local transparency = props.Transparency or 0 + if props.Disabled then + transparency = 1 - 0.5 * (1 - transparency) + end + + return React.createElement("ImageLabel", { + Image = if horizontal then TEX_HORIZONTAL else TEX_VERTICAL, + AnchorPoint = props.AnchorPoint, + Position = props.Position, + Size = props.Size, + BorderSizePixel = 0, + ScaleType = Enum.ScaleType.Tile, + TileSize = if horizontal then UDim2.fromOffset(4, 1) else UDim2.fromOffset(1, 4), + BackgroundTransparency = 1, + ImageColor3 = theme:GetColor(Enum.StudioStyleGuideColor.DimmedText), + ImageTransparency = transparency, + ZIndex = 0, + }) +end + +return DashedLine diff --git a/src/Components/NumberSequencePicker/FreeLine.luau b/src/Components/NumberSequencePicker/FreeLine.luau new file mode 100644 index 0000000..d0e54f4 --- /dev/null +++ b/src/Components/NumberSequencePicker/FreeLine.luau @@ -0,0 +1,37 @@ +local React = require("@pkg/@jsdotlua/react") + +local TEX = "rbxassetid://15434098501" + +local function FreeLine(props: { + Pos0: Vector2, + Pos1: Vector2, + Color: Color3, + Transparency: number?, + ZIndex: number?, + Disabled: boolean?, +}) + local mid = (props.Pos0 + props.Pos1) / 2 + local vector = props.Pos1 - props.Pos0 + local rotation = math.atan2(-vector.X, vector.Y) + math.pi / 2 + local length = vector.Magnitude + + local transparency = props.Transparency or 0 + if props.Disabled then + transparency = 1 - 0.5 * (1 - transparency) + end + + return React.createElement("ImageLabel", { + AnchorPoint = Vector2.one / 2, + Position = UDim2.fromOffset(math.round(mid.X), math.round(mid.Y)), + Size = UDim2.fromOffset(vector.Magnitude, 3), + Rotation = math.deg(rotation), + BackgroundTransparency = 1, + ImageColor3 = props.Color, + ImageTransparency = transparency, + Image = TEX, + ScaleType = if length < 128 then Enum.ScaleType.Crop else Enum.ScaleType.Stretch, + ZIndex = props.ZIndex, + }) +end + +return FreeLine diff --git a/src/Components/NumberSequencePicker/LabelledNumericInput.luau b/src/Components/NumberSequencePicker/LabelledNumericInput.luau new file mode 100644 index 0000000..fb6f4df --- /dev/null +++ b/src/Components/NumberSequencePicker/LabelledNumericInput.luau @@ -0,0 +1,69 @@ +local React = require("@pkg/@jsdotlua/react") + +local Label = require("../Label") +local NumericInput = require("../NumericInput") +local TextInput = require("../TextInput") + +local getTextSize = require("../../getTextSize") + +local PADDING = 5 +local INPUT_WIDTH = 40 + +local function format(n: number) + return string.format(`%.3f`, n) +end + +local noop = function() end + +local function LabelledNumericInput(props: { + Label: string, + Value: number?, + Disabled: boolean?, + OnChanged: (value: number) -> (), + OnSubmitted: (value: number) -> (), + LayoutOrder: number, + Min: number?, + Max: number?, +}) + local textWidth = getTextSize(props.Label).X + + local input: React.ReactNode + if props.Value and not props.Disabled then + local value = props.Value :: number + input = React.createElement(NumericInput, { + Value = value, + Min = props.Min, + Max = props.Max, + Step = 0, + FormatValue = format, + OnValidChanged = props.OnChanged, + OnSubmitted = props.OnSubmitted, + }) + else + input = React.createElement(TextInput, { + Text = if props.Value then format(props.Value) else "", + OnChanged = noop, + Disabled = true, + }) + end + + return React.createElement("Frame", { + Size = UDim2.new(0, textWidth + INPUT_WIDTH + PADDING, 1, 0), + BackgroundTransparency = 1, + LayoutOrder = props.LayoutOrder, + }, { + Label = React.createElement(Label, { + Size = UDim2.new(0, textWidth, 1, 0), + Text = props.Label, + Disabled = props.Value == nil, + }), + Input = React.createElement("Frame", { + AnchorPoint = Vector2.new(1, 0), + Position = UDim2.fromScale(1, 0), + Size = UDim2.new(0, INPUT_WIDTH, 1, 0), + BackgroundTransparency = 1, + }, input), + }) +end + +return LabelledNumericInput diff --git a/src/Components/NumberSequencePicker/SequenceNode.luau b/src/Components/NumberSequencePicker/SequenceNode.luau new file mode 100644 index 0000000..62e6bda --- /dev/null +++ b/src/Components/NumberSequencePicker/SequenceNode.luau @@ -0,0 +1,264 @@ +local React = require("@pkg/@jsdotlua/react") + +local useMouseDrag = require("../../Hooks/useMouseDrag") +local useMouseIcon = require("../../Hooks/useMouseIcon") +local useTheme = require("../../Hooks/useTheme") + +local PickerConstants = require("./Constants") + +local CATCHER_SIZE = 15 +local ENVELOPE_GRAB_HEIGHT = PickerConstants.EnvelopeHandleHeight +local ENVELOPE_TRANSPARENCY = PickerConstants.EnvelopeTransparency +local ENVELOPE_COLOR_STYLE = PickerConstants.EnvelopeColorStyle + +local function EnvelopeHandle(props: { + Top: boolean, + Size: UDim2, + OnDragBegan: () -> (), + OnDragEnded: () -> (), + OnEnvelopeDragged: (y: number, top: boolean) -> (), + Disabled: boolean?, +}) + local theme = useTheme() + + local dragStart = React.useRef(0) + local dragOffset = React.useRef(0) + + local function onDragBegin(rbx: GuiObject, input: InputObject) + local pos = input.Position.Y + local reference + if props.Top then + reference = rbx.AbsolutePosition.Y + else + reference = rbx.AbsolutePosition.Y + rbx.AbsoluteSize.Y + end + dragStart.current = pos + dragOffset.current = reference - pos + props.OnDragBegan() + end + + local drag = useMouseDrag(function(_, input: InputObject) + local position = input.Position.Y + if not dragStart.current or math.abs(position - dragStart.current) > 0 then + local outPosition + if props.Top then + outPosition = position + dragOffset.current :: number + ENVELOPE_GRAB_HEIGHT + else + outPosition = position + dragOffset.current :: number - ENVELOPE_GRAB_HEIGHT + end + props.OnEnvelopeDragged(outPosition, props.Top) + dragStart.current = nil + end + end, { props.OnEnvelopeDragged }, onDragBegin, props.OnDragEnded) + + local hovered, setHovered = React.useState(false) + local mouseIcon = useMouseIcon() + + React.useEffect(function() + if (hovered or drag.isActive()) and not props.Disabled then + mouseIcon.setIcon("rbxasset://SystemCursors/SplitNS") + else + mouseIcon.clearIcon() + end + end, { hovered, drag.isActive(), props.Disabled } :: { unknown }) + + React.useEffect(function() + return function() + mouseIcon.clearIcon() + end + end, {}) + + return React.createElement("TextButton", { + Text = "", + AutoButtonColor = false, + Size = props.Size, + AnchorPoint = Vector2.new(0, if props.Top then 0 else 1), + Position = UDim2.fromScale(0, if props.Top then 0 else 1), + BackgroundTransparency = 1, + BorderSizePixel = 0, + [React.Event.InputBegan] = function(rbx, input) + if input.UserInputType == Enum.UserInputType.MouseMovement then + setHovered(true) + end + drag.onInputBegan(rbx, input) + end, + [React.Event.InputChanged] = drag.onInputChanged, + [React.Event.InputEnded] = function(rbx, input) + if input.UserInputType == Enum.UserInputType.MouseMovement then + setHovered(false) + end + drag.onInputEnded(rbx, input) + end, + ZIndex = 2, + }, { + Visual = React.createElement("Frame", { + AnchorPoint = Vector2.new(0.5, if props.Top then 0 else 1), + Position = UDim2.fromScale(0.5, if props.Top then 0 else 1), + Size = UDim2.fromOffset(if drag.isActive() or hovered then 3 else 1, ENVELOPE_GRAB_HEIGHT + 2), + BorderSizePixel = 0, + BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.MainText), + }, { + Bar = React.createElement("Frame", { + AnchorPoint = Vector2.new(0.5, if props.Top then 1 else 0), + Position = UDim2.fromScale(0.5, if props.Top then 1 else 0), + Size = UDim2.fromOffset(9, if drag.isActive() or hovered then 3 else 1), + BorderSizePixel = 0, + BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.MainText), + }), + }), + }) +end + +local function SequenceNode(props: { + ContentSize: Vector2, + Keypoint: NumberSequenceKeypoint, + OnNodeDragged: (position: Vector2) -> (), + OnEnvelopeDragged: (y: number, top: boolean) -> (), + Active: boolean, + OnHovered: () -> (), + OnDragBegan: () -> (), + OnDragEnded: () -> (), + Disabled: boolean?, +}) + local theme = useTheme() + local mouseIcon = useMouseIcon() + + local nodeDragStart = React.useRef(Vector2.zero) + local nodeDragOffset = React.useRef(Vector2.zero) + local function onNodeDragBegin(rbx: GuiObject, input: InputObject) + local pos = Vector2.new(input.Position.X, input.Position.Y) + local corner = rbx.AbsolutePosition + local center = corner + rbx.AbsoluteSize / 2 + nodeDragStart.current = pos + nodeDragOffset.current = center - pos + props.OnDragBegan() + end + local nodeDrag = useMouseDrag(function(_, input: InputObject) + local position = Vector2.new(input.Position.X, input.Position.Y) + if not nodeDragStart.current or (position - nodeDragStart.current).Magnitude > 0 then + props.OnNodeDragged(position + nodeDragOffset.current :: Vector2) + nodeDragStart.current = nil + end + end, { props.OnNodeDragged }, onNodeDragBegin, props.OnDragEnded) + + local px = math.round(props.Keypoint.Time * props.ContentSize.X) + local py = math.round((1 - props.Keypoint.Value) * props.ContentSize.Y) + + local envelopeHeight = math.round(props.Keypoint.Envelope * props.ContentSize.Y) * 2 + 1 + local fullHeight = envelopeHeight + (ENVELOPE_GRAB_HEIGHT + 1) * 2 + local handleClearance = (fullHeight - CATCHER_SIZE) / 2 - 1 + + local innerSize = if props.Active then 11 else 7 + + local nodeHovered, setNodeHovered = React.useState(false) + React.useEffect(function() + if props.Active and nodeDrag.isActive() then + mouseIcon.setIcon("rbxasset://SystemCursors/ClosedHand") + elseif props.Active and nodeHovered then + mouseIcon.setIcon("rbxasset://SystemCursors/OpenHand") + else + mouseIcon.clearIcon() + end + end, { props.Active, nodeHovered, nodeDrag.isActive() }) + + React.useEffect(function() + if props.Disabled then + mouseIcon.clearIcon() + end + if nodeDrag.isActive() then + nodeDrag.cancel() + end + end, { props.Disabled }) + + local envelopeTransparency = ENVELOPE_TRANSPARENCY + local mainModifier = Enum.StudioStyleGuideModifier.Default + if props.Disabled then + envelopeTransparency = 1 - 0.5 * (1 - envelopeTransparency) + mainModifier = Enum.StudioStyleGuideModifier.Disabled + end + + return React.createElement("Frame", { + Position = UDim2.fromOffset(px - (CATCHER_SIZE - 1) / 2, py - (fullHeight - 1) / 2), + Size = UDim2.fromOffset(CATCHER_SIZE, fullHeight), + BackgroundTransparency = 1, + ZIndex = 2, + }, { + Line = React.createElement("Frame", { + AnchorPoint = Vector2.one / 2, + Position = UDim2.fromScale(0.5, 0.5), + Size = UDim2.fromOffset(1, envelopeHeight), + BorderSizePixel = 0, + BackgroundColor3 = theme:GetColor(ENVELOPE_COLOR_STYLE), + BackgroundTransparency = envelopeTransparency, + ZIndex = 0, + }), + + Node = React.createElement("TextButton", { + Text = "", + AutoButtonColor = false, + AnchorPoint = Vector2.one / 2, + Position = UDim2.fromScale(0.5, 0.5), + Size = UDim2.new(1, 0, 0, CATCHER_SIZE), + BackgroundTransparency = 1, + [React.Event.InputBegan] = function(rbx, input) + if props.Disabled then + return + elseif input.UserInputType == Enum.UserInputType.MouseMovement then + setNodeHovered(true) + props.OnHovered() + end + nodeDrag.onInputBegan(rbx, input) + end, + [React.Event.InputChanged] = function(rbx, input) + if props.Disabled then + return + end + nodeDrag.onInputChanged(rbx, input) + end, + [React.Event.InputEnded] = function(rbx, input) + if props.Disabled then + return + elseif input.UserInputType == Enum.UserInputType.MouseMovement then + setNodeHovered(false) + end + nodeDrag.onInputEnded(rbx, input) + end, + ZIndex = 1, + }, { + Inner = React.createElement("Frame", { + AnchorPoint = Vector2.one / 2, + Position = UDim2.fromScale(0.5, 0.5), + Size = UDim2.fromOffset(innerSize, innerSize), + BackgroundColor3 = if props.Active + then theme:GetColor(Enum.StudioStyleGuideColor.MainBackground, mainModifier) + else theme:GetColor(Enum.StudioStyleGuideColor.InputFieldBackground, mainModifier), + ZIndex = 2, + }, { + Stroke = React.createElement("UIStroke", { + Color = if props.Active + then theme:GetColor(Enum.StudioStyleGuideColor.MainText, mainModifier) + else theme:GetColor(Enum.StudioStyleGuideColor.DimmedText, mainModifier), + Thickness = if nodeDrag.isActive() then 2 else 1, + }), + }), + }), + + Top = props.Active and React.createElement(EnvelopeHandle, { + Top = true, + Size = UDim2.new(1, 0, 0, math.min(ENVELOPE_GRAB_HEIGHT, handleClearance)), + OnDragBegan = props.OnDragBegan, + OnDragEnded = props.OnDragEnded, + OnEnvelopeDragged = props.OnEnvelopeDragged, + }), + + Bottom = props.Active and React.createElement(EnvelopeHandle, { + Top = false, + Size = UDim2.new(1, 0, 0, math.min(ENVELOPE_GRAB_HEIGHT, handleClearance)), + OnDragBegan = props.OnDragBegan, + OnDragEnded = props.OnDragEnded, + OnEnvelopeDragged = props.OnEnvelopeDragged, + }), + }) +end + +return SequenceNode diff --git a/src/Components/NumberSequencePicker/init.luau b/src/Components/NumberSequencePicker/init.luau new file mode 100644 index 0000000..71f2de3 --- /dev/null +++ b/src/Components/NumberSequencePicker/init.luau @@ -0,0 +1,416 @@ +--[=[ + @class NumberSequencePicker + + An interface for modifying [NumberSequence](https://create.roblox.com/docs/reference/engine/datatypes/NumberSequence) values. + This closely resembles the built-in NumberSequence picker for editing properties, with minor adjustments + for improved readability. + + | Dark | Light | + | - | - | + | ![Dark](/components/numbersequencepicker/dark.png) | ![Light](/components/numbersequencepicker/light.png) | + + As this is a controlled component, you should pass a NumberSequence to the `Value` prop + representing the current value, and a callback to the `OnChanged` prop which gets run when the + user attempts to change the sequence using the interface. For example: + + ```lua + local function MyComponent() + local sequence, setSequence = React.useState(NumberSequence.new(...)) + return React.createElement(StudioComponents.NumberSequencePicker, { + Value = sequence, + OnChanged = setSequence, + }) + end + ``` + + The default size of this component is exposed in [Constants.DefaultNumberSequencePickerSize]. + To keep all inputs accessible, it is recommended not to use a smaller size than this. +]=] + +local React = require("@pkg/@jsdotlua/react") + +local CommonProps = require("../../CommonProps") +local Constants = require("../../Constants") +local PickerConstants = require("./Constants") + +local Button = require("../Button") +local useTheme = require("../../Hooks/useTheme") + +local AxisLabel = require("./AxisLabel") +local DashedLine = require("./DashedLine") +local FreeLine = require("./FreeLine") +local LabelledNumericInput = require("./LabelledNumericInput") +local SequenceNode = require("./SequenceNode") + +local function clampVector2(v: Vector2, vmin: Vector2, vmax: Vector2) + return Vector2.new(math.clamp(v.X, vmin.X, vmax.X), math.clamp(v.Y, vmin.Y, vmax.Y)) +end + +--[=[ + @within NumberSequencePicker + @interface Props + @tag Component Props + + @field ... CommonProps + @field Value NumberSequence + @field OnChanged ((newValue: NumberSequence) -> ())? +]=] + +type NumberSequencePickerProps = CommonProps.T & { + Value: NumberSequence, + OnChanged: ((newValue: NumberSequence) -> ())?, +} + +local GRID_ROWS = 4 +local GRID_COLS = 10 + +local ENVELOPE_TRANSPARENCY = PickerConstants.EnvelopeTransparency +local ENVELOPE_COLOR_STYLE = PickerConstants.EnvelopeColorStyle + +local function identity(v) + return v +end + +local function NumberSequencePicker(props: NumberSequencePickerProps) + local theme = useTheme() + local sequence = props.Value + + local onChanged: (NumberSequence) -> () = props.OnChanged or function() end + + local hoveringIndex: number?, setHoveringIndex = React.useState(nil :: number?) + local draggingIndex: number?, setDraggingIndex = React.useState(nil :: number?) + + React.useEffect(function() + if props.Disabled then + setHoveringIndex(nil) + setDraggingIndex(nil) + end + end, { props.Disabled }) + + local contentPos, setContentPos = React.useState(Vector2.zero) + local contentSize, setContentSize = React.useState(Vector2.zero) + + local borders: { [string]: React.ReactNode } = {} + for col = 0, GRID_COLS, 0.5 do + local border = React.createElement(DashedLine, { + Position = UDim2.fromOffset(math.round(col / GRID_COLS * contentSize.X), 0), + Size = UDim2.new(0, 1, 1, 0), + Direction = Enum.FillDirection.Vertical, + Transparency = if col % 1 == 0 then 0 else 0.75, + Disabled = props.Disabled, + }) + borders[`BorderCol{col}`] = border + end + for row = 0, GRID_ROWS, 0.5 do + local border = React.createElement(DashedLine, { + Position = UDim2.fromOffset(0, math.round(row / GRID_ROWS * contentSize.Y)), + Size = UDim2.new(1, 0, 0, 1), + Direction = Enum.FillDirection.Horizontal, + Transparency = if row % 1 == 0 then 0 else 0.75, + Disabled = props.Disabled, + }) + borders[`BorderRow{row}`] = border + end + + local function onNodeDragged(index: number, position: Vector2) + local offset = position - contentPos + offset = clampVector2(offset / contentSize, Vector2.zero, Vector2.one) + offset = Vector2.new(offset.X, 1 - offset.Y) + + local origin = sequence.Keypoints[index] + local before = sequence.Keypoints[index - 1] + local after = sequence.Keypoints[index + 1] + + local minTime = if index == #sequence.Keypoints then 1 elseif index == 1 then 0 else before.Time + local maxTime = if index == 1 then 0 elseif index == #sequence.Keypoints then 1 else after.Time + + local newEnvelope = origin.Envelope + if offset.Y + newEnvelope > 1 then + newEnvelope = 1 - offset.Y + end + if offset.Y - newEnvelope < 0 then + newEnvelope = offset.Y + end + + local newKeypoint = NumberSequenceKeypoint.new(math.clamp(offset.X, minTime, maxTime), offset.Y, newEnvelope) + local newKeypoints = table.clone(sequence.Keypoints) + newKeypoints[index] = newKeypoint + + onChanged(NumberSequence.new(newKeypoints)) + end + + local function onEnvelopeDragged(index: number, y: number, top: boolean) + local keypoint = sequence.Keypoints[index] + local offset = (y - contentPos.Y) / contentSize.Y + local value = 1 - keypoint.Value + + local newEnvelope + local maxValue = math.min(value, 1 - value) + if top then + newEnvelope = math.clamp(value - offset, 0, maxValue) + else + newEnvelope = math.clamp(offset - value, 0, maxValue) + end + + local newKeypoints = table.clone(sequence.Keypoints) + newKeypoints[index] = NumberSequenceKeypoint.new(keypoint.Time, keypoint.Value, newEnvelope) + + onChanged(NumberSequence.new(newKeypoints)) + end + + local points: { [string]: React.ReactNode } = {} + for i, keypoint in sequence.Keypoints do + points[`Point{i}`] = React.createElement(SequenceNode, { + ContentSize = contentSize, + Keypoint = keypoint, + Active = hoveringIndex == i or draggingIndex == i, + Disabled = props.Disabled, + OnHovered = function() + if draggingIndex == nil and hoveringIndex ~= i then + setHoveringIndex(i) + end + end, + OnNodeDragged = function(position) + onNodeDragged(i, position) + end, + OnEnvelopeDragged = function(y, top) + onEnvelopeDragged(i, y, top) + end, + OnDragBegan = function() + setDraggingIndex(i) + end, + OnDragEnded = function() + setDraggingIndex(nil) + end, + }) + end + + local lines: { [string]: React.ReactNode } = {} + for i = 1, #sequence.Keypoints - 1 do + local kp0 = sequence.Keypoints[i] + local kp1 = sequence.Keypoints[i + 1] + local p0 = Vector2.new(kp0.Time, 1 - kp0.Value) + local p1 = Vector2.new(kp1.Time, 1 - kp1.Value) + lines[`Line{i}`] = React.createElement(FreeLine, { + Pos0 = p0 * contentSize, + Pos1 = p1 * contentSize, + Color = theme:GetColor(Enum.StudioStyleGuideColor.DialogMainButton), + ZIndex = 1, + Disabled = props.Disabled, + }) + end + + local envelopes: { [string]: React.ReactNode } = {} + for i = 1, #sequence.Keypoints - 1 do + local kp0 = sequence.Keypoints[i] + local kp1 = sequence.Keypoints[i + 1] + if kp0.Envelope == 0 and kp1.Envelope == 0 then + continue + end + local p0 = Vector2.new(kp0.Time, 1 - kp0.Value) + local p1 = Vector2.new(kp1.Time, 1 - kp1.Value) + envelopes[`EnvelopeOver{i}`] = React.createElement(FreeLine, { + Pos0 = contentSize * Vector2.new(p0.X, p0.Y - kp0.Envelope), -- NB: roblox enforces bound + Pos1 = contentSize * Vector2.new(p1.X, p1.Y - kp1.Envelope), + Color = theme:GetColor(ENVELOPE_COLOR_STYLE), + Transparency = ENVELOPE_TRANSPARENCY, + ZIndex = 1, + Disabled = props.Disabled, + }) + envelopes[`EnvelopeUnder{i}`] = React.createElement(FreeLine, { + Pos0 = contentSize * Vector2.new(p0.X, p0.Y + kp0.Envelope), + Pos1 = contentSize * Vector2.new(p1.X, p1.Y + kp1.Envelope), + Color = theme:GetColor(ENVELOPE_COLOR_STYLE), + Transparency = ENVELOPE_TRANSPARENCY, + ZIndex = 1, + Disabled = props.Disabled, + }) + end + + local axisLabels: { [string]: React.ReactNode } = {} + for row = 1, GRID_ROWS do + axisLabels[`AxisLabelRow{row}`] = React.createElement(AxisLabel, { + AnchorPoint = Vector2.new(1, 0.5), + Position = UDim2.new(0, -5, 1 - row / GRID_ROWS, 0), + TextXAlignment = Enum.TextXAlignment.Right, + Value = row / GRID_ROWS, + Disabled = props.Disabled, + }) + end + for col = 1, GRID_COLS do + axisLabels[`AxisLabelCol{col}`] = React.createElement(AxisLabel, { + AnchorPoint = Vector2.new(0.5, 0), + Position = UDim2.new(col / GRID_COLS, 0, 1, 5), + Value = col / GRID_COLS, + Disabled = props.Disabled, + }) + end + axisLabels["AxisLabelZero"] = React.createElement(AxisLabel, { + AnchorPoint = Vector2.new(1, 0), + Position = UDim2.new(0, -5, 1, 5), + TextXAlignment = Enum.TextXAlignment.Right, + Value = 0, + Disabled = props.Disabled, + }) + + local function tryAddKeypoint(keypoint: NumberSequenceKeypoint) + local newKeypoints = table.clone(sequence.Keypoints) + for i = 1, #sequence.Keypoints do + local kp0 = sequence.Keypoints[i] + local kp1 = sequence.Keypoints[i + 1] + if keypoint.Time >= kp0.Time and keypoint.Time <= kp1.Time then + table.insert(newKeypoints, i + 1, keypoint) + onChanged(NumberSequence.new(newKeypoints)) + setHoveringIndex(i + 1) + break + end + end + end + + local activeIndex = draggingIndex or hoveringIndex + local activeKeypoint = if activeIndex then sequence.Keypoints[activeIndex] else nil + + local minTime, maxTime + if activeKeypoint then + local i = activeIndex :: number + local before = sequence.Keypoints[i - 1] + local after = sequence.Keypoints[i + 1] + minTime = if not after then 1 elseif not before then 0 else before.Time + maxTime = if not before then 0 elseif not after then 1 else after.Time + end + + local minValue = 0 + local maxValue = 1 + + local minEnvelope = 0 + local maxEnvelope + if activeKeypoint then + local current = activeKeypoint :: NumberSequenceKeypoint + maxEnvelope = math.min(current.Value, 1 - current.Value) + end + + local function updateKeypoint(keypointProps: { + Time: number?, + Value: number?, + Envelope: number?, + }) + local current = activeKeypoint :: NumberSequenceKeypoint + local newKeypoints = table.clone(sequence.Keypoints) + newKeypoints[activeIndex :: number] = NumberSequenceKeypoint.new( + keypointProps.Time or current.Time, + keypointProps.Value or current.Value, + keypointProps.Envelope or current.Envelope + ) + onChanged(NumberSequence.new(newKeypoints)) + end + + return React.createElement("Frame", { + AnchorPoint = props.AnchorPoint, + Position = props.Position, + Size = props.Size or Constants.DefaultNumberSequencePickerSize, + LayoutOrder = props.LayoutOrder, + ZIndex = props.ZIndex, + BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.MainBackground), + BorderSizePixel = 0, + }, { + Padding = React.createElement("UIPadding", { + PaddingLeft = UDim.new(0, PickerConstants.EnvelopeHandleHeight + 1), + PaddingRight = UDim.new(0, PickerConstants.EnvelopeHandleHeight + 1), + PaddingTop = UDim.new(0, PickerConstants.EnvelopeHandleHeight + 1), + PaddingBottom = UDim.new(0, 10), + }), + + ContentArea = React.createElement("Frame", { + Position = UDim2.fromOffset(22, 0), -- axis labels + Size = UDim2.fromScale(1, 1) + - UDim2.fromOffset(0, PickerConstants.EnvelopeHandleHeight + 4 + Constants.DefaultInputHeight) + - UDim2.fromOffset(22, 10) -- axis labels + - UDim2.fromOffset(1, 1), -- outer/lower border + BackgroundTransparency = 1, + [React.Change.AbsolutePosition] = function(rbx: Frame) + setContentPos(rbx.AbsolutePosition) + end, + [React.Change.AbsoluteSize] = function(rbx: Frame) + setContentSize(rbx.AbsoluteSize) + end, + [React.Event.InputBegan] = function(rbx: Frame, input: InputObject) + if not draggingIndex and input.UserInputType == Enum.UserInputType.MouseButton1 then + local offset = Vector2.new(input.Position.X, input.Position.Y) - rbx.AbsolutePosition + offset = offset / rbx.AbsoluteSize + offset = clampVector2(offset, Vector2.zero, Vector2.one) + local newKeypoint = NumberSequenceKeypoint.new(offset.X, 1 - offset.Y) + tryAddKeypoint(newKeypoint) + end + end :: () -> (), + }, borders, points, lines, envelopes, axisLabels), + + ControlsArea = React.createElement("Frame", { + AnchorPoint = Vector2.new(0, 1), + Position = UDim2.fromScale(0, 1), + Size = UDim2.new(1, 0, 0, Constants.DefaultInputHeight), + BackgroundTransparency = 1, + BorderSizePixel = 0, + }, { + Layout = React.createElement("UIListLayout", { + FillDirection = Enum.FillDirection.Horizontal, + SortOrder = Enum.SortOrder.LayoutOrder, + Padding = UDim.new(0, 15), + }), + Time = React.createElement(LabelledNumericInput, { + LayoutOrder = 1, + Label = "Time", + Min = minTime, + Max = maxTime, + Value = if activeKeypoint then activeKeypoint.Time else nil, + Disabled = not activeIndex or activeIndex <= 1 or activeIndex >= #sequence.Keypoints, + OnChanged = identity, + OnSubmitted = function(newTime) + updateKeypoint({ Time = math.clamp(newTime, minTime, maxTime) }) + end, + }), + Value = React.createElement(LabelledNumericInput, { + LayoutOrder = 2, + Label = "Value", + Min = minValue, + Max = maxValue, + Value = if activeKeypoint then activeKeypoint.Value else nil, + OnChanged = identity, + OnSubmitted = function(newValue) + updateKeypoint({ Value = math.clamp(newValue, minValue, maxValue) }) + end, + }), + Envelope = React.createElement(LabelledNumericInput, { + LayoutOrder = 3, + Label = "Envelope", + Min = minEnvelope, + Max = maxEnvelope, + Value = if activeKeypoint then activeKeypoint.Envelope else nil, + OnChanged = identity, + OnSubmitted = function(newEnvelope) + updateKeypoint({ Envelope = math.clamp(newEnvelope, minEnvelope, maxEnvelope) }) + end, + }), + Delete = React.createElement("Frame", { + LayoutOrder = 4, + Size = UDim2.new(0, 105, 1, 0), + BackgroundTransparency = 1, + }, { + Button = React.createElement(Button, { + Position = UDim2.fromOffset(0, 1), + Size = UDim2.new(1, 0, 1, -2), + Text = "Delete Keypoint", + Disabled = activeKeypoint == nil or activeIndex <= 1 or activeIndex >= #sequence.Keypoints, + OnActivated = function() + if activeIndex then + local newKeypoints = table.clone(sequence.Keypoints) + table.remove(newKeypoints, activeIndex) + onChanged(NumberSequence.new(newKeypoints)) + end + end, + }), + }), + }), + }) +end + +return NumberSequencePicker diff --git a/src/Components/NumericInput.luau b/src/Components/NumericInput.luau new file mode 100644 index 0000000..4ef1981 --- /dev/null +++ b/src/Components/NumericInput.luau @@ -0,0 +1,335 @@ +--[=[ + @class NumericInput + + An input field matching the appearance of a [TextInput] but which filters inputted text to only + allow numeric values, optionally with arrow and slider controls. + + | Dark | Light | + | - | - | + | ![Dark](/components/numericinput/dark.png) | ![Light](/components/numericinput/light.png) | + + + This is a controlled component with similar behavior to [TextInput]. The current numeric value + to display should be passed to the `Value` prop, and a callback should be passed to the + `OnValidChanged` prop which is run when the user types a (valid) numeric input. + + Optionally, a minimum value can be passed to the `Min` prop, as well as a maximum value to the + `Max` prop. A step (increment) value may also be passed to the the `Step` prop, which defaults + to 1 (allowing only whole number values). Passing a non-integer step value will also allow a + decimal point to be typed in the input box. + + Use the `Arrows` and `Slider` props to specify whether up/down arrows and a slider should be + included. If arrows or a slider are displayed, they will increment the value by the amount of the step. + + Only decimal inputs are allowed (so, for example, hex characters a-f will not be permitted). +]=] + +local RunService = game:GetService("RunService") + +local React = require("@pkg/@jsdotlua/react") + +local BaseTextInput = require("./Foundation/BaseTextInput") +local Slider = require("./Slider") + +local Constants = require("../Constants") +local useFreshCallback = require("../Hooks/useFreshCallback") +local useTheme = require("../Hooks/useTheme") + +--[=[ + @within NumericInput + @interface Props + @tag Component Props + + @field ... CommonProps + + @field Value number + @field OnValidChanged ((n: number) -> ())? + @field Min number? + @field Max number? + @field Step number? + @field OnSubmitted ((n: number) -> ())? + @field FormatValue ((n: number) -> string)? + @field Arrows boolean? + @field Slider boolean? + @field PlaceholderText string? + @field ClearTextOnFocus boolean? + @field OnFocused (() -> ())? + @field OnFocusLost ((text: string, enterPressed: boolean, input: InputObject) -> ())? +]=] + +type NumericInputProps = BaseTextInput.BaseTextInputConsumerProps & { + OnValidChanged: ((n: number) -> ())?, + Value: number, + Min: number?, + Max: number?, + Step: number?, + OnSubmitted: ((n: number) -> ())?, + FormatValue: ((n: number) -> string)?, + Arrows: boolean?, + Slider: boolean?, +} + +local MAX = 2 ^ 53 +local ARROW_WIDTH = 19 +local SLIDER_SPLIT = 0.45 + +local function roundToStep(n: number, step: number): number + if step == 0 then + return n + end + return math.round(n / step) * step +end + +local function tonumberPositiveZero(s: string): number? + local n = tonumber(s) + if n == -0 then + return 0 + end + return n +end + +local function applyFormat(n: number, formatter: ((n: number) -> string)?) + if n == -0 then + n = 0 + end + if formatter ~= nil then + return formatter(n) + end + return tostring(n) +end + +local function NumericInputArrow(props: { + HeightBinding: React.Binding, + Side: number, + Callback: (side: number) -> (), + Disabled: boolean?, +}) + local theme = useTheme() + local connection = React.useRef(nil) :: { current: RBXScriptConnection? } + + local hovered, setHovered = React.useState(false) + local pressed, setPressed = React.useState(false) + local modifier = Enum.StudioStyleGuideModifier.Default + if props.Disabled then + modifier = Enum.StudioStyleGuideModifier.Disabled + elseif pressed then + modifier = Enum.StudioStyleGuideModifier.Pressed + elseif hovered then + modifier = Enum.StudioStyleGuideModifier.Hover + end + + local maybeActivate = useFreshCallback(function() + if hovered then + props.Callback(props.Side) + end + end, { hovered, props.Callback, props.Side } :: { unknown }) + + local function startHolding() + if connection.current then + connection.current:Disconnect() + end + local nextScroll = os.clock() + 0.35 + connection.current = RunService.PostSimulation:Connect(function() + if os.clock() >= nextScroll then + maybeActivate() + nextScroll += 0.05 + end + end) + props.Callback(props.Side) + end + + local function stopHolding() + if connection.current then + connection.current:Disconnect() + connection.current = nil + end + end + React.useEffect(stopHolding, {}) + + React.useEffect(function() + if props.Disabled and pressed then + stopHolding() + setPressed(false) + end + if props.Disabled then + setHovered(false) + end + end, { props.Disabled, pressed }) + + return React.createElement("TextButton", { + AutoButtonColor = false, + BorderSizePixel = 0, + Text = "", + BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Button, modifier), + Size = props.HeightBinding:map(function(height: number) + if props.Side == 0 then + return UDim2.new(1, 0, 0, math.floor(height / 2)) + else + return UDim2.new(1, 0, 0, math.ceil(height / 2) - 1) + end + end), + Position = props.HeightBinding:map(function(height: number) + if props.Side == 0 then + return UDim2.fromOffset(0, 0) + else + return UDim2.fromOffset(0, math.floor(height / 2) + 1) + end + end), + [React.Event.InputBegan] = function(_, input) + if props.Disabled then + return + elseif input.UserInputType == Enum.UserInputType.MouseMovement then + setHovered(true) + elseif input.UserInputType == Enum.UserInputType.MouseButton1 then + setPressed(true) + startHolding() + end + end, + [React.Event.InputEnded] = function(_, input) + if props.Disabled then + return + elseif input.UserInputType == Enum.UserInputType.MouseMovement then + setHovered(false) + elseif input.UserInputType == Enum.UserInputType.MouseButton1 then + setPressed(false) + stopHolding() + end + end, + }, { + Image = React.createElement("ImageLabel", { + Image = "rbxassetid://14699332993", + Size = UDim2.fromOffset(7, 4), + ImageColor3 = theme:GetColor(Enum.StudioStyleGuideColor.ButtonText, modifier), + BackgroundTransparency = 1, + AnchorPoint = Vector2.new(0.5, 0.5), + Position = UDim2.fromScale(0.5, 0.5), + ImageRectSize = Vector2.new(7, 4), + ImageRectOffset = Vector2.new(0, if props.Side == 0 then 0 else 4), + ImageTransparency = if props.Disabled then 0.2 else 0, + }), + }) +end + +local function NumericInput(props: NumericInputProps) + local theme = useTheme() + + local min = math.max(props.Min or -MAX, -MAX) + local max = math.min(props.Max or MAX, MAX) + local step = props.Step or 1 + assert(max >= min, `max ({max}) was less than min ({min})`) + assert(step >= 0, `step ({step}) was less than 0`) + + local pattern = if step % 1 == 0 and step ~= 0 then "[^%-%d]" else "[^%-%.%d]" + local lastCleanText, setLastCleanText = React.useState(applyFormat(props.Value, props.FormatValue)) + + local onValidChanged: (number) -> () = props.OnValidChanged or function() end + + React.useEffect(function() + setLastCleanText(function(freshLastCleanText) + if tonumberPositiveZero(freshLastCleanText) ~= props.Value then + return applyFormat(props.Value, props.FormatValue) + end + return freshLastCleanText + end) + end, { props.Value, props.FormatValue } :: { unknown }) + + local heightBinding, setHeightBinding = React.useBinding(0) + local function buttonCallback(side: number) + local usingStep = (if step == 0 then 1 else step) * (if side == 0 then 1 else -1) + local newValue = math.clamp(props.Value + usingStep, min, max) + if newValue ~= props.Value then + onValidChanged(newValue) + end + end + + local modifier = Enum.StudioStyleGuideModifier.Default + if props.Disabled then + modifier = Enum.StudioStyleGuideModifier.Disabled + end + + return React.createElement("Frame", { + AnchorPoint = props.AnchorPoint, + Position = props.Position, + Size = props.Size or UDim2.new(1, 0, 0, Constants.DefaultInputHeight), + LayoutOrder = props.LayoutOrder, + ZIndex = props.ZIndex, + BorderSizePixel = 0, + BackgroundTransparency = 1, + }, { + InputHolder = React.createElement("Frame", { + Size = UDim2.fromScale(if props.Slider then SLIDER_SPLIT else 1, 1), + BackgroundTransparency = 1, + }, { + Input = React.createElement(BaseTextInput, { + Disabled = props.Disabled, + Size = UDim2.fromScale(1, 1), + ClearTextOnFocus = props.ClearTextOnFocus, + PlaceholderText = props.PlaceholderText, + Text = lastCleanText, + RightPaddingExtra = if props.Arrows then ARROW_WIDTH + 1 else 0, + OnChanged = function(newText: string) + local cleanText = string.gsub(newText, pattern, "") + local number = tonumberPositiveZero(cleanText) + if number ~= nil then + if number >= min and number <= max and roundToStep(number, step) == number then + onValidChanged(number) + end + end + setLastCleanText(cleanText) + end, + OnFocusLost = function(_, enterPressed: boolean, inputObject: InputObject) + local number = tonumberPositiveZero(lastCleanText) + local outValue = props.Value + if number ~= nil then + outValue = math.clamp(roundToStep(number, step), min, max) + end + onValidChanged(outValue) + if props.OnSubmitted then + props.OnSubmitted(outValue) + end + setLastCleanText(applyFormat(outValue, props.FormatValue)) + if props.OnFocusLost then + props.OnFocusLost(lastCleanText, enterPressed, inputObject) + end + end, + OnFocused = props.OnFocused, + }), + Arrows = props.Arrows and React.createElement("Frame", { + BorderSizePixel = 0, + BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Button, modifier), + AnchorPoint = Vector2.new(1, 0), + Position = UDim2.new(1, -1, 0, 1), + Size = UDim2.new(0, ARROW_WIDTH, 1, -2), + [React.Change.AbsoluteSize] = function(rbx) + setHeightBinding(rbx.AbsoluteSize.Y) + end, + }, { + Up = React.createElement(NumericInputArrow, { + Disabled = props.Disabled or props.Value >= max, + Callback = buttonCallback, + HeightBinding = heightBinding, + Side = 0, + }), + Down = React.createElement(NumericInputArrow, { + Disabled = props.Disabled or props.Value <= min, + Callback = buttonCallback, + HeightBinding = heightBinding, + Side = 1, + }), + }), + }), + Slider = props.Slider and React.createElement(Slider, { + AnchorPoint = Vector2.new(1, 0), + Position = UDim2.fromScale(1, 0), + Size = UDim2.new(1 - SLIDER_SPLIT, -5, 1, 0), + Value = props.Value, + Min = min, + Max = max, + Step = step, + OnChanged = props.OnValidChanged, + Disabled = props.Disabled, + }), + }) +end + +return NumericInput diff --git a/src/Components/PluginProvider.luau b/src/Components/PluginProvider.luau new file mode 100644 index 0000000..80e5387 --- /dev/null +++ b/src/Components/PluginProvider.luau @@ -0,0 +1,94 @@ +--[=[ + @class PluginProvider + + This component provides an interface to plugin apis for other components in the tree. It should + be provided with a single `Plugin` prop that must point to `plugin` (your plugin's root instance). + + You do not have to use this component unless you want custom mouse icons via the [useMouseIcon] + hook. Right now, the only built-in component that relies on this is [Splitter]. Theming and all + other functionality will work regardless of whether this component is used. + + You should only render one PluginProvider in your tree. Commonly, this is done at the top of + the tree with the rest of your plugin as children/descendants. + + Example of usage: + + ```lua + local function MyComponent() + return React.createElement(StudioComponents.PluginProvider, { + Plugin = plugin, + }, { + MyExample = React.createElement(MyExample, ...) + }) + end + ``` +]=] + +local HttpService = game:GetService("HttpService") + +local React = require("@pkg/@jsdotlua/react") + +local PluginContext = require("../Contexts/PluginContext") + +type IconStackEntry = { + id: string, + icon: string, +} + +--[=[ + @within PluginProvider + @interface Props + @tag Component Props + + @field Plugin Plugin + @field children React.ReactNode +]=] + +type PluginProviderProps = { + Plugin: Plugin, + children: React.ReactNode, +} + +local function PluginProvider(props: PluginProviderProps) + local plugin = props.Plugin + local iconStack = React.useRef({}) :: { current: { IconStackEntry } } + + local function updateMouseIcon() + local top = iconStack.current[#iconStack.current] + plugin:GetMouse().Icon = if top then top.icon else "" + end + + local function pushMouseIcon(icon) + local id = HttpService:GenerateGUID(false) + table.insert(iconStack.current, { id = id, icon = icon }) + updateMouseIcon() + return id + end + + local function popMouseIcon(id) + for i = #iconStack.current, 1, -1 do + local item = iconStack.current[i] + if item.id == id then + table.remove(iconStack.current, i) + end + end + updateMouseIcon() + end + + React.useEffect(function() + return function() + table.clear(iconStack.current) + plugin:GetMouse().Icon = "" + end + end, {}) + + return React.createElement(PluginContext.Provider, { + value = { + plugin = plugin, + pushMouseIcon = pushMouseIcon, + popMouseIcon = popMouseIcon, + }, + }, props.children) +end + +return PluginProvider diff --git a/src/Components/ProgressBar.luau b/src/Components/ProgressBar.luau new file mode 100644 index 0000000..d9a2f45 --- /dev/null +++ b/src/Components/ProgressBar.luau @@ -0,0 +1,128 @@ +--[=[ + @class ProgressBar + + A basic progress indicator. This should be used for longer or more detailed loading processes. + For shorter loading processes, consider using a [LoadingDots] component. + + | Dark | Light | + | - | - | + | ![Dark](/components/progressbar/dark.png) | ![Light](/components/progressbar/light.png) | + + Pass a number representing the current progress into the `Value` prop. You can optionally pass a + number representing the maximum value into the `Max` prop, which defaults to 1 if not provided. + The `Value` prop should be between 0 and `Max`. For example: + + ```lua + local function MyComponent() + return React.createElement(StudioComponents.ProgressBar, { + Value = 5, -- loaded 5 items + Max = 10, -- out of a total of 10 items + }) + end + ``` + + By default, the progress bar will display text indicating the progress as a percentage, + rounded to the nearest whole number. This can be customized by providing a prop to `Formatter`, + which should be a function that takes two numbers representing the current value and the maximum value + and returns a string to be displayed. For example: + + ```lua + local function MyComponent() + return React.createElement(StudioComponents.ProgressBar, { + Value = 3, + Max = 20, + Formatter = function(current, max) + return `Loaded {current} / {max} assets...` + end, + }) + end + ``` + + By default, the height of a progress bar is equal to the value in [Constants.DefaultProgressBarHeight]. + This can be configured via props. +]=] + +local React = require("@pkg/@jsdotlua/react") + +local CommonProps = require("../CommonProps") +local Constants = require("../Constants") +local useTheme = require("../Hooks/useTheme") + +--[=[ + @within ProgressBar + @interface Props + @tag Component Props + + @field ... CommonProps + @field Value number + @field Max number? + @field Formatter ((value: number, max: number) -> string)? +]=] + +type ProgressBarProps = CommonProps.T & { + Value: number, + Max: number?, + Formatter: ((value: number, max: number) -> string)?, +} + +local function defaultFormatter(value: number, max: number) + return string.format("%i%%", 100 * value / max) +end + +local function ProgressBar(props: ProgressBarProps) + local theme = useTheme() + + local formatter: (number, number) -> string = defaultFormatter + if props.Formatter then + formatter = props.Formatter + end + + local max = props.Max or 1 + local value = math.clamp(props.Value, 0, max) + local alpha = value / max + local text = formatter(value, max) + + local modifier = Enum.StudioStyleGuideModifier.Default + if props.Disabled then + modifier = Enum.StudioStyleGuideModifier.Disabled + end + + return React.createElement("Frame", { + AnchorPoint = props.AnchorPoint, + Position = props.Position, + Size = props.Size or UDim2.new(1, 0, 0, Constants.DefaultProgressBarHeight), + LayoutOrder = props.LayoutOrder, + ZIndex = props.ZIndex, + BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.InputFieldBackground, modifier), + BorderColor3 = theme:GetColor(Enum.StudioStyleGuideColor.InputFieldBorder, modifier), + }, { + Bar = React.createElement("Frame", { + BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.DialogMainButton, modifier), + BorderSizePixel = 0, + Size = UDim2.fromScale(alpha, 1), + ClipsDescendants = true, + ZIndex = 1, + }, { + Left = React.createElement("TextLabel", { + BackgroundTransparency = 1, + Size = UDim2.fromScale(1 / alpha, 1) - UDim2.fromOffset(0, 1), + Font = Constants.DefaultFont, + TextSize = Constants.DefaultTextSize, + TextColor3 = Color3.fromRGB(12, 12, 12), + TextTransparency = if props.Disabled then 0.5 else 0, + Text = text, + }), + }), + Under = React.createElement("TextLabel", { + BackgroundTransparency = 1, + Size = UDim2.fromScale(1, 1) - UDim2.fromOffset(0, 1), + Font = Constants.DefaultFont, + TextSize = Constants.DefaultTextSize, + TextColor3 = theme:GetColor(Enum.StudioStyleGuideColor.MainText, modifier), + Text = text, + ZIndex = 0, + }), + }) +end + +return ProgressBar diff --git a/src/Components/RadioButton.luau b/src/Components/RadioButton.luau new file mode 100644 index 0000000..b32f3fe --- /dev/null +++ b/src/Components/RadioButton.luau @@ -0,0 +1,100 @@ +--[=[ + @class RadioButton + + An input element similar to a [Checkbox] which can either be selected or not selected. + This should be used for an option in a mutually exclusive group of options (the user can + only select one out of the group). This grouping behavior is not included and must be + implemented separately. + + | Dark | Light | + | - | - | + | ![Dark](/components/radiobutton/dark.png) | ![Light](/components/radiobutton/light.png) | + + The props and behavior for this component are the same as [Checkbox]. Importantly, this is + also a controlled component, which means it does not manage its own selected state. A value + must be passed to the `Value` prop and a callback should be passed to the `OnChanged` prop. + For example: + + ```lua + local function MyComponent() + local selected, setSelected = React.useState(false) + return React.createElement(StudioComponents.RadioButton, { + Value = selected, + OnChanged = setSelected, + }) + end + ``` + + For more information about customizing this component via props, refer to the documentation + for [Checkbox]. The default height for this component is found in [Constants.DefaultToggleHeight]. +]=] + +local React = require("@pkg/@jsdotlua/react") + +local BaseLabelledToggle = require("./Foundation/BaseLabelledToggle") +local useTheme = require("../Hooks/useTheme") + +local PADDING = 1 +local INNER_PADDING = 3 + +--[=[ + @within RadioButton + @interface Props + @tag Component Props + + @field ... CommonProps + @field Value boolean? + @field OnChanged (() -> ())? + @field Label string? + @field ContentAlignment HorizontalAlignment? + @field ButtonAlignment HorizontalAlignment? +]=] + +type RadioButtonProps = BaseLabelledToggle.BaseLabelledToggleConsumerProps & { + Value: boolean, +} + +local function RadioButton(props: RadioButtonProps) + local theme = useTheme() + local mergedProps = table.clone(props) :: BaseLabelledToggle.BaseLabelledToggleProps + + function mergedProps.RenderButton(subProps: { Hovered: boolean }) + local mainModifier = Enum.StudioStyleGuideModifier.Default + if props.Disabled then + mainModifier = Enum.StudioStyleGuideModifier.Disabled + elseif subProps.Hovered then + mainModifier = Enum.StudioStyleGuideModifier.Hover + end + + return React.createElement("Frame", { + BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.CheckedFieldBackground, mainModifier), + BackgroundTransparency = 0, + Size = UDim2.new(1, -PADDING * 2, 1, -PADDING * 2), + Position = UDim2.fromOffset(1, PADDING), + }, { + Corner = React.createElement("UICorner", { + CornerRadius = UDim.new(0.5, 0), + }), + Stroke = React.createElement("UIStroke", { + Color = theme:GetColor(Enum.StudioStyleGuideColor.CheckedFieldBorder, mainModifier), + Transparency = 0, + }), + Inner = if props.Value == true + then React.createElement("Frame", { + Size = UDim2.new(1, -INNER_PADDING * 2, 1, -INNER_PADDING * 2), + Position = UDim2.fromOffset(INNER_PADDING, INNER_PADDING), + BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.DialogMainButton, mainModifier), + BackgroundTransparency = 0.25, + }, { + Corner = React.createElement("UICorner", { + CornerRadius = UDim.new(0.5, 0), + }), + }) + else nil, + }) + end + + return React.createElement(BaseLabelledToggle, mergedProps) +end + +return RadioButton diff --git a/src/Components/ScrollFrame/Constants.luau b/src/Components/ScrollFrame/Constants.luau new file mode 100644 index 0000000..8af5fb7 --- /dev/null +++ b/src/Components/ScrollFrame/Constants.luau @@ -0,0 +1,5 @@ +return { + ScrollBarThickness = 16, + WheelScrollAmount = 48, + ArrowScrollAmount = 16, +} diff --git a/src/Components/ScrollFrame/ScrollBar.luau b/src/Components/ScrollFrame/ScrollBar.luau new file mode 100644 index 0000000..5f0ab09 --- /dev/null +++ b/src/Components/ScrollFrame/ScrollBar.luau @@ -0,0 +1,172 @@ +local React = require("@pkg/@jsdotlua/react") + +local useMouseDrag = require("../../Hooks/useMouseDrag") +local useTheme = require("../../Hooks/useTheme") + +local Constants = require("./Constants") +local ScrollBarArrow = require("./ScrollBarArrow") +local Types = require("./Types") + +local SCROLLBAR_THICKNESS = Constants.ScrollBarThickness +local INPUT_MOVE = Enum.UserInputType.MouseMovement + +local function flipVector2(vector: Vector2, shouldFlip: boolean) + return if shouldFlip then Vector2.new(vector.Y, vector.X) else vector +end + +local function flipUDim2(udim: UDim2, shouldFlip: boolean) + return if shouldFlip then UDim2.new(udim.Height, udim.Width) else udim +end + +type ScrollData = Types.ScrollData + +type ScrollBarProps = { + BumpScroll: (scrollVector: Vector2) -> (), + Orientation: Types.BarOrientation, + ScrollData: React.Binding, + ScrollOffset: React.Binding, + SetScrollOffset: (offset: Vector2) -> (), + Disabled: boolean?, +} + +local function ScrollBar(props: ScrollBarProps) + local vertical = props.Orientation == "Vertical" + local scrollDataBinding = props.ScrollData + + local theme = useTheme() + + local hovered, setHovered = React.useState(false) + local dragStartMouse = React.useRef(nil) :: { current: Vector2? } + local dragStartCanvas = React.useRef(nil) :: { current: Vector2? } + + local function onDragStarted(_, input: InputObject) + dragStartMouse.current = Vector2.new(input.Position.X, input.Position.Y) + dragStartCanvas.current = props.ScrollOffset:getValue() + end + + local function onDragEnded() + dragStartMouse.current = nil + dragStartCanvas.current = nil + end + + local function onDragged(_, input: InputObject) + local scrollData = scrollDataBinding:getValue() + local contentSize = scrollData.ContentSize + local windowSize = scrollData.WindowSize + local innerBarSize = scrollData.InnerBarSize + + local offsetFrom = dragStartCanvas.current :: Vector2 + local mouseFrom = dragStartMouse.current :: Vector2 + local mouseDelta = Vector2.new(input.Position.X, input.Position.Y) - mouseFrom + + local shiftAlpha = mouseDelta / (innerBarSize - scrollData.BarSize) + local overflow = contentSize - windowSize + + local newOffset = offsetFrom + overflow * shiftAlpha + newOffset = newOffset:Min(overflow) + newOffset = newOffset:Max(Vector2.zero) + + local freshScrollOffset = props.ScrollOffset:getValue() + if vertical then + props.SetScrollOffset(Vector2.new(freshScrollOffset.X, newOffset.Y)) + else + props.SetScrollOffset(Vector2.new(newOffset.X, freshScrollOffset.Y)) + end + end + + local dragDeps = { props.ScrollOffset, props.SetScrollOffset } :: { unknown } + local drag = useMouseDrag(onDragged, dragDeps, onDragStarted, onDragEnded) + + React.useEffect(function() + if props.Disabled and drag.isActive() then + drag.cancel() + onDragEnded() + end + -- if props.Disabled then + -- setHovered(false) + -- end + end, { props.Disabled, drag.isActive() }) + + local modifier = Enum.StudioStyleGuideModifier.Default + if props.Disabled then + modifier = Enum.StudioStyleGuideModifier.Disabled + elseif hovered or drag.isActive() then + modifier = Enum.StudioStyleGuideModifier.Pressed + end + + return React.createElement("Frame", { + BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.ScrollBarBackground), + BorderColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Border), + Visible = scrollDataBinding:map(function(data: ScrollData) + return if vertical then data.BarVisible.Y else data.BarVisible.X + end), + AnchorPoint = flipVector2(Vector2.new(1, 0), not vertical), + Position = flipUDim2(UDim2.fromScale(1, 0), not vertical), + Size = scrollDataBinding:map(function(data) + local extra = if (vertical and data.BarVisible.X) or (not vertical and data.BarVisible.Y) + then -SCROLLBAR_THICKNESS - 1 + else 0 + return flipUDim2(UDim2.new(0, SCROLLBAR_THICKNESS, 1, extra), not vertical) + end), + }, { + Arrow0 = React.createElement(ScrollBarArrow, { + Side = 0, + Orientation = props.Orientation, + BumpScroll = props.BumpScroll, + Disabled = props.Disabled, + }), + Arrow1 = React.createElement(ScrollBarArrow, { + Side = 1, + Orientation = props.Orientation, + BumpScroll = props.BumpScroll, + Position = flipUDim2(UDim2.fromScale(0, 1), not vertical), + AnchorPoint = flipVector2(Vector2.new(0, 1), not vertical), + Disabled = props.Disabled, + }), + Region = React.createElement("Frame", { + BackgroundTransparency = 1, + Position = flipUDim2(UDim2.fromOffset(0, SCROLLBAR_THICKNESS + 1), not vertical), + Size = flipUDim2(UDim2.new(1, 0, 1, -(SCROLLBAR_THICKNESS + 1) * 2), not vertical), + }, { + Handle = React.createElement("Frame", { + BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.ScrollBar, modifier), + BorderColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Border, modifier), + Size = scrollDataBinding:map(function(data: ScrollData) + local size = if vertical then data.BarSize.Y else data.BarSize.X + return flipUDim2(UDim2.new(1, 0, 0, size), not vertical) + end), + Position = scrollDataBinding:map(function(data: ScrollData) + local position = if vertical then data.BarPosition.Y else data.BarPosition.X + return flipUDim2(UDim2.fromScale(0, position), not vertical) + end), + AnchorPoint = scrollDataBinding:map(function(data: ScrollData) + local position = if vertical then data.BarPosition.Y else data.BarPosition.X + return flipVector2(Vector2.new(0, position), not vertical) + end), + [React.Event.InputBegan] = function(rbx: Frame, input: InputObject) + if input.UserInputType == INPUT_MOVE then + setHovered(true) + end + if not props.Disabled then + drag.onInputBegan(rbx, input) + end + end, + [React.Event.InputChanged] = function(rbx: Frame, input: InputObject) + if not props.Disabled then + drag.onInputChanged(rbx, input) + end + end, + [React.Event.InputEnded] = function(rbx: Frame, input: InputObject) + if input.UserInputType == INPUT_MOVE then + setHovered(false) + end + if not props.Disabled then + drag.onInputEnded(rbx, input) + end + end, + }), + }), + }) +end + +return ScrollBar diff --git a/src/Components/ScrollFrame/ScrollBarArrow.luau b/src/Components/ScrollFrame/ScrollBarArrow.luau new file mode 100644 index 0000000..941f936 --- /dev/null +++ b/src/Components/ScrollFrame/ScrollBarArrow.luau @@ -0,0 +1,129 @@ +local RunService = game:GetService("RunService") + +local React = require("@pkg/@jsdotlua/react") + +local useFreshCallback = require("../../Hooks/useFreshCallback") +local useTheme = require("../../Hooks/useTheme") + +local Constants = require("./Constants") +local Types = require("./Types") + +local ARROW_IMAGE = "rbxassetid://6677623152" +local SCROLLBAR_THICKNESS = Constants.ScrollBarThickness +local ARROW_SCROLL_AMOUNT = Constants.ArrowScrollAmount + +local function getArrowImageOffset(orientation: Types.BarOrientation, side: number) + if orientation == "Vertical" then + return Vector2.new(0, side * SCROLLBAR_THICKNESS) + end + return Vector2.new(SCROLLBAR_THICKNESS, side * SCROLLBAR_THICKNESS) +end + +local function getScrollVector(orientation: Types.BarOrientation, side: number) + local scrollAmount = ARROW_SCROLL_AMOUNT * (if side == 0 then -1 else 1) + local scrollVector = Vector2.new(0, scrollAmount) + if orientation == "Horizontal" then + scrollVector = Vector2.new(scrollAmount, 0) + end + return scrollVector +end + +type ScrollBarArrowProps = { + BumpScroll: (scrollVector: Vector2) -> (), + Orientation: Types.BarOrientation, + Side: number, + Position: UDim2?, + AnchorPoint: Vector2?, + Disabled: boolean?, +} + +local function ScrollBarArrow(props: ScrollBarArrowProps) + local theme = useTheme() + local connection = React.useRef(nil) :: { current: RBXScriptConnection? } + + local pressed, setPressed = React.useState(false) + local hovered, setHovered = React.useState(false) + + local modifier = Enum.StudioStyleGuideModifier.Default + if props.Disabled then + modifier = Enum.StudioStyleGuideModifier.Disabled + elseif pressed then + modifier = Enum.StudioStyleGuideModifier.Pressed + end + + local maybeScroll = useFreshCallback(function() + if hovered then + props.BumpScroll(getScrollVector(props.Orientation, props.Side)) + end + end, { hovered, props.BumpScroll, props.Orientation, props.Side } :: { unknown }) + + local function startHolding() + if connection.current then + connection.current:Disconnect() + end + local nextScroll = os.clock() + 0.35 + connection.current = RunService.PostSimulation:Connect(function() + if os.clock() >= nextScroll then + maybeScroll() + nextScroll += 0.05 + end + end) + props.BumpScroll(getScrollVector(props.Orientation, props.Side)) + end + + local function stopHolding() + if connection.current then + connection.current:Disconnect() + connection.current = nil + end + end + React.useEffect(stopHolding, {}) + + React.useEffect(function() + if props.Disabled and pressed then + stopHolding() + setPressed(false) + end + if props.Disabled then + setHovered(false) + end + end, { props.Disabled, pressed }) + + local hostClass = "ImageLabel" + local hostProps = { + BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.ScrollBar, modifier), + BorderColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Border, modifier), + Size = UDim2.fromOffset(SCROLLBAR_THICKNESS, SCROLLBAR_THICKNESS), + Image = ARROW_IMAGE, + ImageRectSize = Vector2.new(SCROLLBAR_THICKNESS, SCROLLBAR_THICKNESS), + ImageRectOffset = getArrowImageOffset(props.Orientation, props.Side), + ImageColor3 = theme:GetColor(Enum.StudioStyleGuideColor.TitlebarText, modifier), + Position = props.Position, + AnchorPoint = props.AnchorPoint, + } + + if props.Disabled ~= true then + hostClass = "ImageButton" + hostProps.AutoButtonColor = false + hostProps[React.Event.InputBegan] = function(_, input: InputObject) + if input.UserInputType == Enum.UserInputType.MouseMovement then + setHovered(true) + elseif input.UserInputType == Enum.UserInputType.MouseButton1 then + setPressed(true) + startHolding() + end + end + hostProps[React.Event.InputEnded] = function(_, input: InputObject) + if input.UserInputType == Enum.UserInputType.MouseMovement then + setHovered(false) + elseif input.UserInputType == Enum.UserInputType.MouseButton1 then + setPressed(false) + stopHolding() + end + end + end + + return React.createElement(hostClass, hostProps) +end + +return ScrollBarArrow diff --git a/src/Components/ScrollFrame/Types.luau b/src/Components/ScrollFrame/Types.luau new file mode 100644 index 0000000..ba22012 --- /dev/null +++ b/src/Components/ScrollFrame/Types.luau @@ -0,0 +1,12 @@ +export type ScrollData = { + ContentSize: Vector2, + WindowSize: Vector2, + InnerBarSize: Vector2, + BarVisible: { X: boolean, Y: boolean }, + BarSize: Vector2, + BarPosition: Vector2, +} + +export type BarOrientation = "Horizontal" | "Vertical" + +return {} diff --git a/src/Components/ScrollFrame/init.luau b/src/Components/ScrollFrame/init.luau new file mode 100644 index 0000000..e95429a --- /dev/null +++ b/src/Components/ScrollFrame/init.luau @@ -0,0 +1,368 @@ +--[=[ + @class ScrollFrame + + A container with scrollable contents. This works the same way as a built-in [ScrollingFrame] but + has visual changes to match the appearance of built-in scrollers in Studio. + + | Dark | Light | + | - | - | + | ![Dark](/components/scrollframe/dark.png) | ![Light](/components/scrollframe/light.png) | + + ScrollFrames automatically size their canvas to fit their contents, which are passed via the + `children` parameters in createElement. By default, children are laid out with a [UIListLayout]; + this can be overriden via the `Layout` prop. Either "UIListLayout" or "UIGridLayout" may be + passed to `Layout.ClassName`. Any other properties to be applied to the layout should also be + passed in the `Layout` prop. For example: + + ```lua + local function MyComponent() + return React.createElement(StudioComponents.ScrollFrame, { + Layout = { + ClassName = "UIListLayout", + Padding = UDim.new(0, 10), + FillDirection = Enum.FillDirection.Horizontal, + } + }, { + SomeChild = React.createElement(...), + AnotherChild = React.createElement(...), + }) + end + ``` + + By default, scrolling on both the X and Y axes is enabled. To configure this, pass an + [Enum.ScrollingDirection] value to the `ScrollingDirection` prop. Padding around the outside of + contents can also be configured via the `PaddingLeft`, `PaddingRight`, `PaddingTop`, and + `PaddingBottom` props. + + :::info + The built-in Studio scrollers were changed during this project's lifetime to be significantly + narrower. This component retains the old, wider, size because it is more accessible. + ::: +]=] + +local React = require("@pkg/@jsdotlua/react") + +local CommonProps = require("../../CommonProps") +local useTheme = require("../../Hooks/useTheme") + +local Constants = require("./Constants") +local ScrollBar = require("./ScrollBar") +local Types = require("./Types") + +local SCROLL_WHEEL_SPEED = Constants.WheelScrollAmount +local SCROLLBAR_THICKNESS = Constants.ScrollBarThickness +local SCROLLBAR_MIN_LENGTH = SCROLLBAR_THICKNESS + +local function clampVector2(v: Vector2, vmin: Vector2, vmax: Vector2) + return Vector2.new(math.clamp(v.X, vmin.X, vmax.X), math.clamp(v.Y, vmin.Y, vmax.Y)) +end + +type ScrollData = Types.ScrollData + +local defaultLayout = { + ClassName = "UIListLayout", + SortOrder = Enum.SortOrder.LayoutOrder, +} + +--[=[ + @within ScrollFrame + @interface Props + @tag Component Props + + @field ... CommonProps + @field Layout { ClassName: string, [string]: any }? + @field ScrollingDirection Enum.ScrollingDirection? + @field PaddingLeft UDim? + @field PaddingRight UDim? + @field PaddingTop UDim? + @field PaddingBottom UDim? + @field OnScrolled ((scrollOffset: Vector2) -> ())? + @field children React.ReactNode +]=] + +type ScrollFrameProps = CommonProps.T & { + ScrollingDirection: Enum.ScrollingDirection?, + PaddingLeft: UDim?, + PaddingRight: UDim?, + PaddingTop: UDim?, + PaddingBottom: UDim?, + OnScrolled: ((scrollOffset: Vector2) -> ())?, + Layout: { + ClassName: string, -- Luau: should be "UIListLayout | "UIGridLayout" but causes problems + [string]: any, -- native props + }?, + children: React.ReactNode, +} + +local function computePaddingSize(data: { + PaddingLeft: UDim?, + PaddingRight: UDim?, + PaddingTop: UDim?, + PaddingBottom: UDim?, + WindowSize: Vector2, +}) + local paddingX = (data.PaddingLeft or UDim.new(0, 0)) + (data.PaddingRight or UDim.new(0, 0)) + local paddingY = (data.PaddingTop or UDim.new(0, 0)) + (data.PaddingBottom or UDim.new(0, 0)) + return Vector2.new( + paddingX.Scale * data.WindowSize.X + paddingX.Offset, + paddingY.Scale * data.WindowSize.Y + paddingY.Offset + ) +end + +local function getRegionData(props: ScrollFrameProps, contentSize: Vector2, windowSize: Vector2) + local scrollingEnabled = { + X = props.ScrollingDirection ~= Enum.ScrollingDirection.Y, + Y = props.ScrollingDirection ~= Enum.ScrollingDirection.X, + } + + local paddingSize = computePaddingSize({ + PaddingLeft = props.PaddingLeft, + PaddingRight = props.PaddingRight, + PaddingTop = props.PaddingTop, + PaddingBottom = props.PaddingBottom, + WindowSize = windowSize, + }) + contentSize += paddingSize + + local overflow = contentSize - windowSize + local visible = { X = false, Y = false } + for _ = 1, 2 do -- in case Y relayout affects X, we do it twice + if overflow.X > 0 and not visible.X and scrollingEnabled.X then + windowSize -= Vector2.new(0, SCROLLBAR_THICKNESS + 1) + visible.X = true + end + if overflow.Y > 0 and not visible.Y and scrollingEnabled.Y then + windowSize -= Vector2.new(SCROLLBAR_THICKNESS + 1, 0) + visible.Y = true + end + overflow = contentSize - windowSize + end + + return { + ContentSize = contentSize, + WindowSize = windowSize, + Overflow = overflow, + Visible = visible, + } +end + +local function ScrollFrame(props: ScrollFrameProps) + local theme = useTheme() + + local scrollOffsetBinding, setScrollOffsetBinding = React.useBinding(Vector2.zero) + local contentSizeBinding, setContentSizeBinding = React.useBinding(Vector2.zero) + local windowSizeBinding, setWindowSizeBinding = React.useBinding(Vector2.zero) + + local function setScrollOffset(offset: Vector2) + if offset ~= scrollOffsetBinding:getValue() then + setScrollOffsetBinding(offset) + if props.OnScrolled then + props.OnScrolled(offset) + end + end + end + + local function getScrollData(): ScrollData + local regionData = getRegionData(props, contentSizeBinding:getValue(), windowSizeBinding:getValue()) + local contentSize = regionData.ContentSize + local windowSize = regionData.WindowSize + local overflow = regionData.Overflow + local visible = regionData.Visible + + local alpha = clampVector2(windowSize / contentSize, Vector2.zero, Vector2.one) + local innerBarSize = windowSize - Vector2.one * (SCROLLBAR_THICKNESS + 1) * 2 + + local scrollOffset = scrollOffsetBinding:getValue() + local offset = alpha * innerBarSize + offset = offset:Max(Vector2.one * SCROLLBAR_MIN_LENGTH) + offset = offset:Min(innerBarSize + Vector2.one * 2) + offset = offset:Max(Vector2.zero) + + local sizeX = if visible.X then offset.X else 0 + local sizeY = if visible.Y then offset.Y else 0 + local size = Vector2.new(sizeX, sizeY) + local position = clampVector2(scrollOffset / overflow, Vector2.zero, Vector2.one) + + return { + ContentSize = contentSize, + WindowSize = windowSize, + InnerBarSize = innerBarSize, + BarVisible = visible, + BarSize = size, + BarPosition = position, + } + end + + local function revalidateScrollOffset() + local regionData = getRegionData(props, contentSizeBinding:getValue(), windowSizeBinding:getValue()) + local currentOffset = scrollOffsetBinding:getValue() + local maxOffset = regionData.Overflow:Max(Vector2.zero) + maxOffset = maxOffset:Min( + Vector2.new( + if regionData.Visible.X then maxOffset.X else 0, + if regionData.Visible.Y then maxOffset.Y else 0 + ) + ) + local newOffset = currentOffset:Min(maxOffset) + if newOffset ~= currentOffset then + setScrollOffset(newOffset) + end + end + + React.useEffect( + revalidateScrollOffset, + { + props.ScrollingDirection, + props.Layout, + props.PaddingLeft, + props.PaddingRight, + props.PaddingBottom, + props.PaddingTop, + } :: { unknown } + ) + + local function bumpScroll(scrollVector: Vector2) + local scrollData = getScrollData() + local windowSize = scrollData.WindowSize + local contentSize = scrollData.ContentSize + local scrollOffset = scrollOffsetBinding:getValue() + local scrollTarget = scrollOffset + scrollVector + local scrollBounds = contentSize - windowSize + scrollBounds = scrollBounds:Max(Vector2.zero) + setScrollOffset(clampVector2(scrollTarget, Vector2.zero, scrollBounds)) + end + + local function handleMainInput(_, input: InputObject) + if props.Disabled then + return + elseif input.UserInputType == Enum.UserInputType.MouseWheel then + local amount = SCROLL_WHEEL_SPEED * -input.Position.Z + local scrollData = getScrollData() + local shiftHeld = input:IsModifierKeyDown(Enum.ModifierKey.Shift) + local scrollVector + if shiftHeld then + if scrollData.BarVisible.X then + scrollVector = Vector2.new(amount, 0) + end + elseif scrollData.BarVisible.Y then + scrollVector = Vector2.new(0, amount) + elseif scrollData.BarVisible.X then + scrollVector = Vector2.new(amount, 0) + end + if scrollVector then + bumpScroll(scrollVector) + end + end + end + + local scrollDataBinding = React.joinBindings({ + contentSizeBinding, + windowSizeBinding, + scrollOffsetBinding, + }):map(getScrollData) + + local modifier = Enum.StudioStyleGuideModifier.Default + if props.Disabled then + modifier = Enum.StudioStyleGuideModifier.Disabled + end + + local layoutBase = table.clone(props.Layout or defaultLayout) + local layoutProps: { [any]: any } = {} + for key, val in layoutBase :: { [string]: any } do + if key ~= "ClassName" then + layoutProps[key] = val + end + end + layoutProps[React.Change.AbsoluteContentSize] = function(rbx: UIListLayout | UIGridLayout) + setContentSizeBinding(rbx.AbsoluteContentSize) + revalidateScrollOffset() + end + + --[[ + Children need to be able to use actual window size for their sizes. + To facilitate this, the parent Window frame's size equals the clipping area. + However, this causes the layout in e.g. layout mode Center to lay elements out + ... from the center of the Window, rather than the Canvas. + We fix this by simply overriding the alignments when the bars are visible + ... since those properties make no difference in those cases. + ]] + layoutProps.HorizontalAlignment = scrollDataBinding:map(function(data: ScrollData) + return if data.BarVisible.X then Enum.HorizontalAlignment.Left else layoutBase.HorizontalAlignment + end) + layoutProps.VerticalAlignment = scrollDataBinding:map(function(data: ScrollData) + return if data.BarVisible.Y then Enum.VerticalAlignment.Top else layoutBase.VerticalAlignment + end) + + local mainSize = scrollDataBinding:map(function(data: ScrollData) + local windowSize = data.WindowSize:Max(Vector2.zero) + return UDim2.fromOffset(windowSize.X, windowSize.Y) + end) + + return React.createElement("Frame", { + -- sinks scroll input that would otherwise zoom the studio camera + -- also prevents the drag-box appearing on lmb-drag + Active = true, + LayoutOrder = props.LayoutOrder, + ZIndex = props.ZIndex, + AnchorPoint = props.AnchorPoint, + Position = props.Position, + Size = props.Size or UDim2.fromScale(1, 1), + BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.MainBackground, modifier), + BorderColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Border, modifier), + [React.Event.InputBegan] = handleMainInput, + [React.Event.InputChanged] = handleMainInput, + [React.Change.AbsoluteSize] = function(rbx: Frame) + setWindowSizeBinding(rbx.AbsoluteSize) + revalidateScrollOffset() + end :: any, + }, { + Cover = if props.Disabled + then React.createElement("Frame", { + ZIndex = 2, + Size = mainSize, + BorderSizePixel = 0, + BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.MainBackground), + BackgroundTransparency = 0.25, + }) + else nil, + Clipping = React.createElement("Frame", { + Size = mainSize, + BackgroundTransparency = 1, + ClipsDescendants = true, + }, { + Window = React.createElement("Frame", { + Size = UDim2.fromScale(1, 1), + BackgroundTransparency = 1, + Position = scrollOffsetBinding:map(function(offset: Vector2) + return UDim2.fromOffset(-offset.X, -offset.Y) + end), + }, { + Padding = React.createElement("UIPadding", { + PaddingLeft = props.PaddingLeft, + PaddingRight = props.PaddingRight, + PaddingTop = props.PaddingTop, + PaddingBottom = props.PaddingBottom, + }), + Layout = React.createElement(layoutBase.ClassName, layoutProps), + }, props.children), + }), + VerticalScrollBar = React.createElement(ScrollBar, { + Orientation = "Vertical" :: "Vertical", -- Luau + ScrollData = scrollDataBinding, + ScrollOffset = scrollOffsetBinding, + SetScrollOffset = setScrollOffset, + BumpScroll = bumpScroll, + Disabled = props.Disabled, + }), + HorizontalScrollBar = React.createElement(ScrollBar, { + Orientation = "Horizontal" :: "Horizontal", -- Luau + ScrollData = scrollDataBinding, + ScrollOffset = scrollOffsetBinding, + SetScrollOffset = setScrollOffset, + BumpScroll = bumpScroll, + Disabled = props.Disabled, + }), + }) +end + +return ScrollFrame diff --git a/src/Components/Slider.luau b/src/Components/Slider.luau new file mode 100644 index 0000000..093ca2b --- /dev/null +++ b/src/Components/Slider.luau @@ -0,0 +1,206 @@ +--[=[ + @class Slider + + A component for selecting a numeric value from a range of values with an optional increment. + These are seen in some number-valued properties in the built-in Properties widget, as well as in + various built-in plugins such as the Terrain Editor. + + | Dark | Light | + | - | - | + | ![Dark](/components/slider/dark.png) | ![Light](/components/slider/light.png) | + + As with other components in this library, this is a controlled component. You should pass a + value to the `Value` prop representing the current value, as well as a callback to the `OnChanged` + prop which will be run when the user changes the value via dragging or clicking on the slider. + + In addition to these, you must also provide a `Min` and a `Max` prop, which together define the + range of the slider. Optionally, a `Step` prop can be provided, which defines the increment of + the slider. This defaults to 0, which allows any value in the range. For a complete example: + + ```lua + local function MyComponent() + local value, setValue = React.useState(1) + return React.createElement(StudioComponents.Slider, { + Value = value, + OnChanged = setValue, + Min = 0, + Max = 10, + Step = 1, + }) + end + ``` + + Two further props can optionally be provided: + 1. `Border` determines whether a border is drawn around the component. + This is useful for giving visual feedback when the slider is hovered or selected. + 2. `Background` determines whether the component has a visible background. + If this is value is missing or set to `false`, any border will also be hidden. + + Both of these props default to `true`. + + By default, the height of sliders is equal to the value found in [Constants.DefaultSliderHeight]. + While this can be overriden by props, in order to keep inputs accessible it is not recommended + to make the component any smaller than this. +]=] + +local React = require("@pkg/@jsdotlua/react") + +local CommonProps = require("../CommonProps") +local Constants = require("../Constants") +local useMouseDrag = require("../Hooks/useMouseDrag") +local useTheme = require("../Hooks/useTheme") + +--[=[ + @within Slider + @interface Props + @tag Component Props + + @field ... CommonProps + @field Value number + @field OnChanged ((newValue: number) -> ())? + @field Min number + @field Max number + @field Step number? + @field Border boolean? + @field Background boolean? +]=] + +type SliderProps = CommonProps.T & { + Value: number, + OnChanged: ((newValue: number) -> ())?, + Min: number, + Max: number, + Step: number?, + Border: boolean?, + Background: boolean?, +} + +local PADDING_BAR_SIDE = 3 +local PADDING_REGION_TOP = 1 +local PADDING_REGION_SIDE = 6 + +local INPUT_MOVE = Enum.UserInputType.MouseMovement + +local function Slider(props: SliderProps) + local theme = useTheme() + + local onChanged: (number) -> () = props.OnChanged or function() end + + local drag = useMouseDrag(function(rbx: GuiObject, input: InputObject) + local regionPos = rbx.AbsolutePosition.X + PADDING_REGION_SIDE + local regionSize = rbx.AbsoluteSize.X - PADDING_REGION_SIDE * 2 + local inputPos = input.Position.X + + local alpha = (inputPos - regionPos) / regionSize + local step = props.Step or 0 + + local value = props.Min * (1 - alpha) + props.Max * alpha + if step > 0 then + value = math.round(value / step) * step + end + value = math.clamp(value, props.Min, props.Max) + if value ~= props.Value then + onChanged(value) + end + end, { props.Value, props.Min, props.Max, props.Step, onChanged } :: { unknown }) + + local hovered, setHovered = React.useState(false) + local mainModifier = Enum.StudioStyleGuideModifier.Default + if props.Disabled then + mainModifier = Enum.StudioStyleGuideModifier.Disabled + end + + local handleModifier = Enum.StudioStyleGuideModifier.Default + if props.Disabled then + handleModifier = Enum.StudioStyleGuideModifier.Disabled + elseif hovered or drag.isActive() then + handleModifier = Enum.StudioStyleGuideModifier.Hover + end + + local handleFill = theme:GetColor(Enum.StudioStyleGuideColor.Button, handleModifier) + local handleBorder = theme:GetColor(Enum.StudioStyleGuideColor.Border, handleModifier) + + React.useEffect(function() + if props.Disabled and drag.isActive() then + drag.cancel() + end + end, { props.Disabled, drag.isActive() }) + + local function inputBegan(rbx: Frame, input: InputObject) + if input.UserInputType == INPUT_MOVE then + setHovered(true) + end + if not props.Disabled then + drag.onInputBegan(rbx, input) + end + end + + local function inputChanged(rbx: Frame, input: InputObject) + if not props.Disabled then + drag.onInputChanged(rbx, input) + end + end + + local function inputEnded(rbx: Frame, input: InputObject) + if input.UserInputType == INPUT_MOVE then + setHovered(false) + end + if not props.Disabled then + drag.onInputEnded(rbx, input) + end + end + + -- if we use a Frame here, the 2d studio selection rectangle will appear when dragging + -- we could prevent that using Active = true, but that displays the Click cursor + -- ... the best workaround is a TextButton with Active = false + return React.createElement("TextButton", { + Text = "", + Active = false, + AutoButtonColor = false, + Size = props.Size or UDim2.new(1, 0, 0, Constants.DefaultSliderHeight), + Position = props.Position, + AnchorPoint = props.AnchorPoint, + LayoutOrder = props.LayoutOrder, + ZIndex = props.ZIndex, + BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.InputFieldBackground, mainModifier), + BorderColor3 = theme:GetColor(Enum.StudioStyleGuideColor.InputFieldBorder, handleModifier), + BorderMode = Enum.BorderMode.Inset, + BorderSizePixel = if props.Border == false then 0 else 1, + BackgroundTransparency = if props.Background == false then 1 else 0, + [React.Event.InputBegan] = inputBegan, + [React.Event.InputChanged] = inputChanged, + [React.Event.InputEnded] = inputEnded, + }, { + Bar = React.createElement("Frame", { + ZIndex = 1, + AnchorPoint = Vector2.new(0, 0.5), + Position = UDim2.new(0, PADDING_BAR_SIDE, 0.5, 0), + Size = UDim2.new(1, -PADDING_BAR_SIDE * 2, 0, 2), + BorderSizePixel = 0, + BackgroundTransparency = props.Disabled and 0.4 or 0, + BackgroundColor3 = theme:GetColor( + -- surprising values, but provides correct colors + Enum.StudioStyleGuideColor.TitlebarText, + Enum.StudioStyleGuideModifier.Disabled + ), + }), + HandleRegion = React.createElement("Frame", { + ZIndex = 2, + Position = UDim2.fromOffset(PADDING_REGION_SIDE, PADDING_REGION_TOP), + Size = UDim2.new(1, -PADDING_REGION_SIDE * 2, 1, -PADDING_REGION_TOP * 2), + BackgroundTransparency = 1, + }, { + Handle = React.createElement("Frame", { + AnchorPoint = Vector2.new(0.5, 0), + Position = UDim2.fromScale((props.Value - props.Min) / (props.Max - props.Min), 0), + Size = UDim2.new(0, 10, 1, 0), + BorderMode = Enum.BorderMode.Inset, + BorderSizePixel = 1, + BorderColor3 = handleBorder:Lerp(handleFill, props.Disabled and 0.5 or 0), + BackgroundColor3 = handleFill, + }), + }), + }) +end + +return Slider diff --git a/src/Components/Splitter.luau b/src/Components/Splitter.luau new file mode 100644 index 0000000..76b3b60 --- /dev/null +++ b/src/Components/Splitter.luau @@ -0,0 +1,226 @@ +--[=[ + @class Splitter + + A container frame similar to a [Background] but split into two panels, with a draggable control + for resizing the panels within the container. Resizing one section to be larger will reduce the + size of the other section, and vice versa. This is useful for letting users resize content. + + | Dark | Light | + | - | - | + | ![Dark](/components/splitter/dark.png) | ![Light](/components/splitter/light.png) | + + This is a controlled component. The current split location should be passed as a number between + 0 and 1 to the `Alpha` prop, and a callback should be passed to the `OnChanged` prop, which + is run with the new alpha value when the user uses the splitter. + + You can also optionally provide `MinAlpha` and `MaxAlpha` props (numbers between 0 and 1) which + limit the resizing. These values default to 0.1 and 0.9. + + To render children in each side, use the `children` parameters in createElement and provide the + keys `Side0` and `Side1`. For a complete example: + + ```lua + local function MyComponent() + local division, setDivision = React.useState(0.5) + return React.createElement(StudioComponents.Splitter, { + Alpha = division, + OnChanged = setDivision, + }, { + Side0 = React.createElement(...), + Side1 = React.createElement(...), + }) + end + ``` + + By default, the split is horizontal, which means that the frame is split into a left and right + side. This can be changed, for example to a vertical split (top and bottom), by providing an + [Enum.FillDirection] value to the `FillDirection` prop. + + This component can use your system's splitter mouse icons when interacting with the splitter bar. + To enable this behavior, ensure you have rendered a [PluginProvider] somewhere higher up in + the tree. +]=] + +local React = require("@pkg/@jsdotlua/react") + +local CommonProps = require("../CommonProps") + +local useMouseDrag = require("../Hooks/useMouseDrag") +local useMouseIcon = require("../Hooks/useMouseIcon") +local useTheme = require("../Hooks/useTheme") + +local function flipVector2(vector: Vector2, shouldFlip: boolean) + return if shouldFlip then Vector2.new(vector.Y, vector.X) else vector +end + +local function flipUDim2(udim: UDim2, shouldFlip: boolean) + return if shouldFlip then UDim2.new(udim.Height, udim.Width) else udim +end + +local HANDLE_THICKNESS = 6 +local DEFAULT_MIN_ALPHA = 0.1 +local DEFAULT_MAX_ALPHA = 0.9 + +--[=[ + @within Splitter + @interface Props + @tag Component Props + + @field ... CommonProps + + @field Alpha number + @field OnChanged ((newAlpha: number) -> ())? + @field FillDirection Enum.FillDirection? + @field MinAlpha number? + @field MaxAlpha number? + + @field children { Side0: React.ReactNode?, Side1: React.ReactNode? }? +]=] + +export type SplitterProps = CommonProps.T & { + Alpha: number, + OnChanged: ((newAlpha: number) -> ())?, + FillDirection: Enum.FillDirection?, + MinAlpha: number?, + MaxAlpha: number?, + children: { + Side0: React.ReactNode, + Side1: React.ReactNode, + }?, +} + +local icons = { + [Enum.FillDirection.Horizontal] = "rbxasset://SystemCursors/SplitEW", + [Enum.FillDirection.Vertical] = "rbxasset://SystemCursors/SplitNS", +} + +local function Splitter(props: SplitterProps) + local theme = useTheme() + local mouseIcon = useMouseIcon() + + local fillDirection = props.FillDirection or Enum.FillDirection.Horizontal + local children = props.children or { + Side0 = nil, + Side1 = nil, + } + + local drag = useMouseDrag(function(bar: GuiObject, input: InputObject) + local region = bar.Parent :: Frame + local position = Vector2.new(input.Position.X, input.Position.Y) + local alpha = (position - region.AbsolutePosition) / region.AbsoluteSize + alpha = alpha:Max(Vector2.one * (props.MinAlpha or DEFAULT_MIN_ALPHA)) + alpha = alpha:Min(Vector2.one * (props.MaxAlpha or DEFAULT_MAX_ALPHA)) + if props.OnChanged then + if fillDirection == Enum.FillDirection.Horizontal and alpha.X ~= props.Alpha then + props.OnChanged(alpha.X) + elseif fillDirection == Enum.FillDirection.Vertical and alpha.Y ~= props.Alpha then + props.OnChanged(alpha.Y) + end + end + end, { props.Alpha, props.OnChanged, props.MinAlpha, props.MaxAlpha, fillDirection } :: { unknown }) + + React.useEffect(function() + if props.Disabled and drag.isActive() then + drag.cancel() + end + end, { props.Disabled, drag.isActive() }) + + local hovered, setHovered = React.useState(false) + + React.useEffect(function() + if (hovered or drag.isActive()) and not props.Disabled then + local icon = icons[fillDirection] + mouseIcon.setIcon(icon) + else + mouseIcon.clearIcon() + end + end, { mouseIcon, hovered, drag.isActive(), props.Disabled, fillDirection } :: { unknown }) + + local function onInputBegan(rbx: Frame, input: InputObject) + if input.UserInputType == Enum.UserInputType.MouseMovement then + setHovered(true) + end + if not props.Disabled then + drag.onInputBegan(rbx, input) + end + end + local function onInputChanged(rbx: Frame, input: InputObject) + if not props.Disabled then + drag.onInputChanged(rbx, input) + end + end + local function onInputEnded(rbx: Frame, input: InputObject) + if input.UserInputType == Enum.UserInputType.MouseMovement then + setHovered(false) + end + if not props.Disabled then + drag.onInputEnded(rbx, input) + end + end + + local shouldFlip = fillDirection == Enum.FillDirection.Vertical + local alpha = props.Alpha + alpha = math.max(alpha, props.MinAlpha or DEFAULT_MIN_ALPHA) + alpha = math.min(alpha, props.MaxAlpha or DEFAULT_MAX_ALPHA) + + local handleTransparency = if props.Disabled then 0.75 else 0 + local handleColorStyle = Enum.StudioStyleGuideColor.DialogButton + if props.Disabled then + handleColorStyle = Enum.StudioStyleGuideColor.Border + end + + return React.createElement("Frame", { + AnchorPoint = props.AnchorPoint, + Position = props.Position, + Size = props.Size or UDim2.fromScale(1, 1), + LayoutOrder = props.LayoutOrder, + ZIndex = props.ZIndex, + BackgroundTransparency = 1, + }, { + Handle = React.createElement("Frame", { + Active = true, -- prevents the drag-box when in coregui mode + AnchorPoint = flipVector2(Vector2.new(0.5, 0), shouldFlip), + Position = flipUDim2(UDim2.fromScale(alpha, 0), shouldFlip), + Size = flipUDim2(UDim2.new(0, HANDLE_THICKNESS, 1, 0), shouldFlip), + BackgroundTransparency = handleTransparency, + BackgroundColor3 = theme:GetColor(handleColorStyle), + BorderSizePixel = 0, + [React.Event.InputBegan] = onInputBegan, + [React.Event.InputChanged] = onInputChanged, + [React.Event.InputEnded] = onInputEnded, + ZIndex = 1, + }, { + LeftBorder = not props.Disabled and React.createElement("Frame", { + Size = flipUDim2(UDim2.new(0, 1, 1, 0), shouldFlip), + BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Border), + BorderSizePixel = 0, + }), + RightBorder = not props.Disabled and React.createElement("Frame", { + Position = flipUDim2(UDim2.new(1, -1, 0, 0), shouldFlip), + Size = flipUDim2(UDim2.new(0, 1, 1, 0), shouldFlip), + BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Border), + BorderSizePixel = 0, + }), + }), + Side0 = React.createElement("Frame", { + Size = flipUDim2(UDim2.new(alpha, -math.floor(HANDLE_THICKNESS / 2), 1, 0), shouldFlip), + BackgroundTransparency = 1, + ClipsDescendants = true, + ZIndex = 0, + }, { + Child = children.Side0, + }), + Side1 = React.createElement("Frame", { + AnchorPoint = flipVector2(Vector2.new(1, 0), shouldFlip), + Position = flipUDim2(UDim2.fromScale(1, 0), shouldFlip), + Size = flipUDim2(UDim2.new(1 - alpha, -math.ceil(HANDLE_THICKNESS / 2), 1, 0), shouldFlip), + BackgroundTransparency = 1, + ClipsDescendants = true, + ZIndex = 0, + }, { + Child = children.Side1, + }), + }) +end + +return Splitter diff --git a/src/Components/TabContainer.luau b/src/Components/TabContainer.luau new file mode 100644 index 0000000..0a4c95f --- /dev/null +++ b/src/Components/TabContainer.luau @@ -0,0 +1,242 @@ +--[=[ + @class TabContainer + + A container that displays one content page at a time, where different pages can be selected + via a set of tabs along the top. This is seen in some built-in plugins such as the Toolbox. + + | Dark | Light | + | - | - | + | ![Dark](/components/tabcontainer/dark.png) | ![Light](/components/tabcontainer/light.png) | + + This is a controlled component. The identifier of the selected tab should be passed to the + `SelectedTab` prop, and a callback should be passed to the `OnTabSelected` prop which is run + when the user selects a tab from the tab controls along the top. + + The content rendered in each tab's main window should be passed to the `children` parameters in + `createElement` in the [format](TabContainer#Tab) described below. The keys are used as tab names + in the tab controls along the top and should also correspond to the identifier in `SelectedTab` + and the identifiers that `OnTabSelected` prop may be called with. For example: + + ```lua + local function MyComponent() + local selectedTab, setSelectedTab = React.useState("Models") + return React.createElement(TabContainer, { + SelectedTab = selectedTab, + OnTabSelected = setSelectedTab, + }, { + ["Models"] = { + LayoutOrder = 1, + Content = React.createElement(...), + }, + ["Decals"] = { + LayoutOrder = 2, + Content = React.createElement(...), + } + }) + end + ``` + + As well as disabling the entire component via the `Disabled` [CommonProp](CommonProps), individual + tabs can be disabled and made unselectable by passing `Disabled` with a value of `true` inside + the tab's entry in the `Tabs` prop table. + + :::info + The various tab containers found in Studio are inconsistent with each other (for example, Toolbox + and Terrain Editor use different sizes, colors, and highlights). This design of this component + uses the common elements of those designs and has small tweaks to stay consistent with the wider + design of Studio elements. + ::: +]=] + +local React = require("@pkg/@jsdotlua/react") + +local CommonProps = require("../CommonProps") +local useTheme = require("../Hooks/useTheme") + +local TAB_HEIGHT = 30 + +--[=[ + @within TabContainer + @interface Tab + + @field LayoutOrder number + @field Content React.ReactNode + @field Disabled boolean? +]=] + +type Tab = { + Content: React.ReactNode, + LayoutOrder: number, + Disabled: boolean?, +} + +--[=[ + @within TabContainer + @interface Props + @tag Component Props + + @field ... CommonProps + @field SelectedTab string + @field OnTabSelected ((name: string) -> ())? + @field children { [string]: Tab } +]=] + +type TabContainerProps = CommonProps.T & { + SelectedTab: string, + OnTabSelected: ((name: string) -> ())?, + children: { [string]: Tab }?, +} + +local function TabButton(props: { + Size: UDim2, + Text: string, + LayoutOrder: number, + Selected: boolean, + OnActivated: () -> (), + Disabled: boolean?, +}) + local theme = useTheme() + + local hovered, setHovered = React.useState(false) + local pressed, setPressed = React.useState(false) + + local onInputBegan = function(_, input) + if input.UserInputType == Enum.UserInputType.MouseButton1 then + setPressed(true) + elseif input.UserInputType == Enum.UserInputType.MouseMovement then + setHovered(true) + end + end + + local onInputEnded = function(_, input) + if input.UserInputType == Enum.UserInputType.MouseButton1 then + setPressed(false) + elseif input.UserInputType == Enum.UserInputType.MouseMovement then + setHovered(false) + end + end + + local backgroundStyle = Enum.StudioStyleGuideColor.Button + if props.Selected then + backgroundStyle = Enum.StudioStyleGuideColor.MainBackground + elseif pressed and not props.Disabled then + backgroundStyle = Enum.StudioStyleGuideColor.ButtonBorder + end + + local modifier = Enum.StudioStyleGuideModifier.Default + if props.Disabled then + modifier = Enum.StudioStyleGuideModifier.Disabled + elseif props.Selected then + modifier = Enum.StudioStyleGuideModifier.Pressed + elseif hovered then + modifier = Enum.StudioStyleGuideModifier.Hover + end + + local indicatorModifier = Enum.StudioStyleGuideModifier.Default + if props.Disabled then + indicatorModifier = Enum.StudioStyleGuideModifier.Disabled + end + + return React.createElement("TextButton", { + AutoButtonColor = false, + BackgroundColor3 = theme:GetColor(backgroundStyle, modifier), + BorderColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Border, modifier), + LayoutOrder = props.LayoutOrder, + Size = props.Size, + Text = props.Text, + Font = Enum.Font.SourceSans, + TextColor3 = theme:GetColor(Enum.StudioStyleGuideColor.MainText, modifier), + TextTruncate = Enum.TextTruncate.AtEnd, + TextSize = 14, + [React.Event.InputBegan] = onInputBegan, + [React.Event.InputEnded] = onInputEnded, + [React.Event.Activated] = function() + if not props.Disabled then + props.OnActivated() + end + end, + }, { + Indicator = props.Selected and React.createElement("Frame", { + BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.DialogMainButton, indicatorModifier), + BorderSizePixel = 0, + Size = UDim2.new(1, 0, 0, 2), + }), + Under = props.Selected and React.createElement("Frame", { + BackgroundColor3 = theme:GetColor(backgroundStyle, modifier), + BorderSizePixel = 0, + Size = UDim2.new(1, 0, 0, 1), + Position = UDim2.fromScale(0, 1), + }), + }) +end + +local function TabContainer(props: TabContainerProps) + local theme = useTheme() + + local children = props.children :: { [string]: Tab } + local tabs: { [string]: React.ReactNode } = {} + local count = 0 + for _ in children do + count += 1 + end + + for name, tab in children do + local isSelectedTab = props.SelectedTab == name + tabs[name] = React.createElement(TabButton, { + Size = UDim2.fromScale(1 / count, 1), + LayoutOrder = tab.LayoutOrder, + Text = name, + Selected = isSelectedTab, + Disabled = tab.Disabled == true or props.Disabled == true, + OnActivated = function() + if props.OnTabSelected then + props.OnTabSelected(name) + end + end, + }) + end + + local tab = children[props.SelectedTab] + local content = if tab then tab.Content else nil + + local modifier = Enum.StudioStyleGuideModifier.Default + if props.Disabled then + modifier = Enum.StudioStyleGuideModifier.Disabled + end + + return React.createElement("Frame", { + BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.MainBackground, modifier), + BorderColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Border, modifier), + AnchorPoint = props.AnchorPoint, + Position = props.Position, + Size = props.Size or UDim2.fromScale(1, 1), + LayoutOrder = props.LayoutOrder, + ZIndex = props.ZIndex, + }, { + Top = React.createElement("Frame", { + ZIndex = 2, + Size = UDim2.new(1, 0, 0, TAB_HEIGHT), + BackgroundTransparency = 1, + }, { + TabsContainer = React.createElement("Frame", { + Size = UDim2.fromScale(1, 1), + BackgroundTransparency = 1, + }, { + Layout = React.createElement("UIListLayout", { + SortOrder = Enum.SortOrder.LayoutOrder, + FillDirection = Enum.FillDirection.Horizontal, + }), + }, tabs), + }), + Content = React.createElement("Frame", { + ZIndex = 1, + AnchorPoint = Vector2.new(0, 1), + Position = UDim2.fromScale(0, 1), + Size = UDim2.new(1, 0, 1, -TAB_HEIGHT - 1), -- extra px for outer border + BackgroundTransparency = 1, + ClipsDescendants = true, + }, content), + }) +end + +return TabContainer diff --git a/src/Components/TextInput.luau b/src/Components/TextInput.luau new file mode 100644 index 0000000..57a6ce7 --- /dev/null +++ b/src/Components/TextInput.luau @@ -0,0 +1,91 @@ +--[=[ + @class TextInput + + A basic input field for entering any kind of text. This matches the appearance of the search + boxes in the Explorer and Properties widgets, among other inputs in Studio. + + | Dark | Light | + | - | - | + | ![Dark](/components/textinput/dark.png) | ![Light](/components/textinput/light.png) | + + This is a controlled component, which means the current text should be passed in to the + `Text` prop and a callback value to the `OnChanged` prop which gets run when the user attempts + types in the input field. For example: + + ```lua + local function MyComponent() + local text, setText = React.useState("") + return React.createElement(StudioComponents.TextInput, { + Text = text, + OnChanged = setText, + }) + end + ``` + + This allows complete control over the text displayed and keeps the source of truth in your own + code. This is helpful for consistency and controlling the state from elsewhere in the tree. It + also allows you to easily filter what can be typed into the text input. For example, to only + permit entering lowercase letters: + + ```lua + local function MyComponent() + local text, setText = React.useState("") + return React.createElement(StudioComponents.TextInput, { + Text = text, + OnChanged = function(newText), + local filteredText = string.gsub(newText, "[^a-z]", "") + setText(filteredText) + end, + }) + end + ``` + + By default, the height of this component is equal to the value in [Constants.DefaultInputHeight]. + While this can be overriden by props, in order to keep inputs accessible it is not recommended + to make the component any smaller than this. +]=] + +local React = require("@pkg/@jsdotlua/react") + +local BaseTextInput = require("./Foundation/BaseTextInput") +local Constants = require("../Constants") + +--[=[ + @within TextInput + @interface Props + @tag Component Props + + @field ... CommonProps + + @field Text string + @field OnChanged ((newText: string) -> ())? + + @field PlaceholderText string? + @field ClearTextOnFocus boolean? + @field OnFocused (() -> ())? + @field OnFocusLost ((text: string, enterPressed: boolean, input: InputObject) -> ())? +]=] + +type TextInputProps = BaseTextInput.BaseTextInputConsumerProps & { + Text: string, + OnChanged: ((newText: string) -> ())?, +} + +local function TextInput(props: TextInputProps) + return React.createElement(BaseTextInput, { + AnchorPoint = props.AnchorPoint, + Position = props.Position, + Size = props.Size or UDim2.new(1, 0, 0, Constants.DefaultInputHeight), + LayoutOrder = props.LayoutOrder, + ZIndex = props.ZIndex, + Disabled = props.Disabled, + Text = props.Text, + PlaceholderText = props.PlaceholderText, + ClearTextOnFocus = props.ClearTextOnFocus, + OnFocused = props.OnFocused, + OnFocusLost = props.OnFocusLost, + OnChanged = props.OnChanged or function() end, + }, props.children) +end + +return TextInput diff --git a/src/Constants.lua b/src/Constants.lua deleted file mode 100644 index b892e40..0000000 --- a/src/Constants.lua +++ /dev/null @@ -1,21 +0,0 @@ -return { - Font = Enum.Font.SourceSans, - FontBold = Enum.Font.SourceSansBold, - TextSize = 14, - - CheckboxAlignment = { - Left = "Left", - Right = "Right", - }, - CheckboxIndeterminate = "Indeterminate", - - SplitterOrientation = { - Horizontal = "Horizontal", - Vertical = "Vertical", - }, - - ZIndex = { - Dropdown = 2 ^ 31 - 2, - Tooltip = 2 ^ 31 - 1, - }, -} diff --git a/src/Constants.luau b/src/Constants.luau new file mode 100644 index 0000000..1805eea --- /dev/null +++ b/src/Constants.luau @@ -0,0 +1,69 @@ +--[=[ + @class Constants + This module exposes values that are read from in various components. + These can be used to, for example, match the appearance of custom components with components + from this library. + + :::warning + The table returned by this module is read-only. It is not a config. + ::: +]=] + +local Constants = {} + +--- @within Constants +--- @prop DefaultFont Font +--- The default font for text. +Constants.DefaultFont = Enum.Font.SourceSans + +--- @within Constants +--- @prop DefaultTextSize number +--- The default size for text. +Constants.DefaultTextSize = 14 + +--- @within Constants +--- @prop DefaultButtonHeight number +--- The default height of buttons. +Constants.DefaultButtonHeight = 24 + +--- @within Constants +--- @prop DefaultToggleHeight number +--- The default height of toggles (Checkbox and RadioButton). +Constants.DefaultToggleHeight = 20 + +--- @within Constants +--- @prop DefaultInputHeight number +--- The default height of text and numeric inputs. +Constants.DefaultInputHeight = 22 + +--- @within Constants +--- @prop DefaultSliderHeight number +--- The default height of sliders. +Constants.DefaultSliderHeight = 22 + +--- @within Constants +--- @prop DefaultDropdownHeight number +--- The default height of the permanent section of dropdowns. +Constants.DefaultDropdownHeight = 20 + +--- @within Constants +--- @prop DefaultDropdownRowHeight number +--- The default height of rows in dropdown lists. +Constants.DefaultDropdownRowHeight = 16 + +--- @within Constants +--- @prop DefaultProgressBarHeight number +--- The default height of progress bars. +Constants.DefaultProgressBarHeight = 14 + +--- @within Constants +--- @prop DefaultColorPickerSize UDim2 +--- The default window size of color pickers. +Constants.DefaultColorPickerSize = UDim2.fromOffset(260, 285) + +--- @within Constants +--- @prop DefaultNumberSequencePickerSize UDim2 +--- The default window size of number sequence pickers. +Constants.DefaultNumberSequencePickerSize = UDim2.fromOffset(425, 285) + +return table.freeze(Constants) diff --git a/src/Contexts/PluginContext.luau b/src/Contexts/PluginContext.luau new file mode 100644 index 0000000..36a2759 --- /dev/null +++ b/src/Contexts/PluginContext.luau @@ -0,0 +1,9 @@ +local React = require("@pkg/@jsdotlua/react") + +export type PluginContext = { + plugin: Plugin, + pushMouseIcon: (icon: string) -> string, + popMouseIcon: (id: string) -> (), +} + +return React.createContext(nil :: PluginContext?) diff --git a/src/Contexts/ThemeContext.luau b/src/Contexts/ThemeContext.luau new file mode 100644 index 0000000..b5e8246 --- /dev/null +++ b/src/Contexts/ThemeContext.luau @@ -0,0 +1,3 @@ +local React = require("@pkg/@jsdotlua/react") + +return React.createContext(nil :: StudioTheme?) diff --git a/src/Dropdown.story.lua b/src/Dropdown.story.lua deleted file mode 100644 index ab37de9..0000000 --- a/src/Dropdown.story.lua +++ /dev/null @@ -1,80 +0,0 @@ -local Packages = script.Parent.Parent -local Roact = require(Packages.Roact) - -local Dropdown = require(script.Parent.Dropdown) - -local words = string.split( - "Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua", - " " -) -table.insert(words, "Long final test dropdown option") - -local Wrapper = Roact.Component:extend("Wrapper") - -function Wrapper:init() - self:setState({ - Item0 = "Lorem", - Item1 = "dolor", - Item2 = "sit", - Item3 = "amet", - }) -end - -function Wrapper:render() - return Roact.createFragment({ - Layout = Roact.createElement("UIGridLayout", { - CellPadding = UDim2.new(0, 10, 0, 10), - CellSize = UDim2.new(0.3, 0, 0, 20), - SortOrder = Enum.SortOrder.LayoutOrder, - FillDirection = Enum.FillDirection.Horizontal, - VerticalAlignment = Enum.VerticalAlignment.Center, - HorizontalAlignment = Enum.HorizontalAlignment.Center, - FillDirectionMaxCells = 2, - }), - - Dropdown0 = Roact.createElement(Dropdown, { - LayoutOrder = 0, - Items = table.clone(words), - SelectedItem = self.state.Item0, - OnItemSelected = function(item) - self:setState({ Item0 = item }) - end, - }), - - Dropdown1 = Roact.createElement(Dropdown, { - LayoutOrder = 1, - Items = table.clone(words), - SelectedItem = self.state.Item1, - OnItemSelected = function(item) - self:setState({ Item1 = item }) - end, - }), - - Dropdown2 = Roact.createElement(Dropdown, { - LayoutOrder = 2, - Items = table.clone(words), - SelectedItem = self.state.Item2, - OnItemSelected = function(item) - self:setState({ Item2 = item }) - end, - }), - - Dropdown3 = Roact.createElement(Dropdown, { - LayoutOrder = 3, - Items = table.clone(words), - SelectedItem = self.state.Item3, - OnItemSelected = function(item) - self:setState({ Item3 = item }) - end, - Disabled = true, - }), - }) -end - -return function(target) - local element = Roact.createElement(Wrapper) - local handle = Roact.mount(element, target) - return function() - Roact.unmount(handle) - end -end diff --git a/src/Dropdown/DropdownItem.lua b/src/Dropdown/DropdownItem.lua deleted file mode 100644 index 8acc1b5..0000000 --- a/src/Dropdown/DropdownItem.lua +++ /dev/null @@ -1,55 +0,0 @@ -local Packages = script.Parent.Parent.Parent - -local Roact = require(Packages.Roact) -local Hooks = require(Packages.RoactHooks) - -local Constants = require(script.Parent.Parent.Constants) -local useTheme = require(script.Parent.Parent.useTheme) - -local function DropdownItem(props, hooks) - local theme = useTheme(hooks) - local hovered, setHovered = hooks.useState(false) - - local onInputBegan = function(_rbx, input) - if input.UserInputType == Enum.UserInputType.MouseMovement then - setHovered(true) - end - end - - local onInputEnded = function(_rbx, input) - if input.UserInputType == Enum.UserInputType.MouseMovement then - setHovered(false) - end - end - - local modifier = Enum.StudioStyleGuideModifier.Default - if hovered then - modifier = Enum.StudioStyleGuideModifier.Hover - end - - return Roact.createElement("TextButton", { - AutoButtonColor = false, - LayoutOrder = props.LayoutOrder, - Size = UDim2.new(1, 0, 0, props.RowHeightItem), - BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.EmulatorBar, modifier), - BorderSizePixel = 0, - Text = props.Item, - Font = Constants.Font, - TextSize = Constants.TextSize, - TextColor3 = theme:GetColor(Enum.StudioStyleGuideColor.MainText, modifier), - TextXAlignment = Enum.TextXAlignment.Left, - TextTruncate = Enum.TextTruncate.AtEnd, - [Roact.Event.InputBegan] = onInputBegan, - [Roact.Event.InputEnded] = onInputEnded, - [Roact.Event.Activated] = function() - props.OnSelected(props.Item) - end, - }, { - Padding = Roact.createElement("UIPadding", { - PaddingLeft = UDim.new(0, props.TextPaddingLeft - 1), - PaddingRight = UDim.new(0, props.TextPaddingRight), - }), - }) -end - -return Hooks.new(Roact)(DropdownItem) diff --git a/src/Dropdown/init.lua b/src/Dropdown/init.lua deleted file mode 100644 index e6ba1bc..0000000 --- a/src/Dropdown/init.lua +++ /dev/null @@ -1,214 +0,0 @@ -local Packages = script.Parent.Parent - -local Roact = require(Packages.Roact) -local Hooks = require(Packages.RoactHooks) - -local Constants = require(script.Parent.Constants) -local useTheme = require(script.Parent.useTheme) - -local ScrollFrame = require(script.Parent.ScrollFrame) -local DropdownItem = require(script.DropdownItem) - -local TEXT_PADDING_LEFT = 5 -local TEXT_PADDING_RIGHT = 3 - -local catchInputs = { - [Enum.UserInputType.MouseButton1] = true, - [Enum.UserInputType.MouseButton2] = true, - [Enum.UserInputType.MouseButton3] = true, -} - -local defaultProps = { - Width = UDim.new(1, 0), - MaxVisibleRows = 6, - RowHeightTop = 20, - RowHeightItem = 15, -} - -local function Dropdown(props, hooks) - local theme = useTheme(hooks) - - local open, setOpen = hooks.useState(false) - local hovered, setHovered = hooks.useState(false) - - local rootRef = hooks.useValue(Roact.createRef()) - - local onSelectedInputBegan = function(_, input) - local t = input.UserInputType - if t == Enum.UserInputType.MouseMovement then - if not props.Disabled then - setHovered(true) - end - elseif t == Enum.UserInputType.MouseButton1 then - if not props.Disabled then - setOpen(not open) - end - end - end - - local onSelectedInputEnded = function(_, input) - if input.UserInputType == Enum.UserInputType.MouseMovement then - setHovered(false) - end - end - - local onSelectedItem = function(item) - if not props.Disabled then - setOpen(false) - props.OnItemSelected(item) - end - end - - local modifier = Enum.StudioStyleGuideModifier.Default - if props.Disabled then - modifier = Enum.StudioStyleGuideModifier.Disabled - elseif hovered then - modifier = Enum.StudioStyleGuideModifier.Hover - end - - local background = Enum.StudioStyleGuideColor.MainBackground - if open or hovered then - background = Enum.StudioStyleGuideColor.InputFieldBackground - end - - local items = {} - if open and not props.Disabled then - for i, item in ipairs(props.Items) do - items[i] = Roact.createElement(DropdownItem, { - Item = item, - LayoutOrder = i, - OnSelected = onSelectedItem, - RowHeightItem = props.RowHeightItem, - TextPaddingLeft = TEXT_PADDING_LEFT, - TextPaddingRight = TEXT_PADDING_RIGHT, - }) - end - end - - local rowPadding = 1 - local visibleItems = math.min(props.MaxVisibleRows, #items) - local scrollHeight = visibleItems * props.RowHeightItem -- item heights - + (visibleItems - 1) * rowPadding -- row padding - + 2 -- top and bottom borders - - local catcher = nil - local function onCatcherInputBegan(_, input) - local t = input.UserInputType - if catchInputs[t] then - local inst = rootRef.value:getValue() - local off = Vector2.new(input.Position.x, input.Position.y) - inst.AbsolutePosition - local max = inst.AbsoluteSize - if off.x < 0 or off.x > max.x or off.y < 0 or off.y > max.y then - setOpen(false) -- only run if not clicking over the dropdown top part - end - elseif t == Enum.UserInputType.Keyboard then - if input.KeyCode == Enum.KeyCode.Escape then - setOpen(false) - end - end - end - - if not props.Disabled and open and rootRef.value then - local inst = rootRef.value:getValue() - local target = inst:FindFirstAncestorWhichIsA("LayerCollector") - - if target ~= nil then - local pos = inst.AbsolutePosition - local size = inst.AbsoluteSize - - local spaceBelow = target.AbsoluteSize.y - size.y - pos.y - local spaceAbove = pos.y - - -- render dropdown going upward if both are true: - -- 1. not enough space below AND - -- 2. more space above - local anchor = Vector2.new(0, 0) - local posy = math.ceil(pos.y) - 1 + props.RowHeightTop - local buffer = 3 -- extra space required below - if spaceBelow < scrollHeight + buffer and spaceAbove > spaceBelow then - anchor = Vector2.new(0, 1) - posy -= props.RowHeightTop - end - - catcher = Roact.createElement(Roact.Portal, { - target = target, - }, { - Frame = Roact.createElement("Frame", { - ZIndex = Constants.ZIndex.Dropdown, - BackgroundTransparency = 1, - Size = UDim2.fromScale(1, 1), - [Roact.Event.InputBegan] = onCatcherInputBegan, - }, { - -- rounding etc. here corrects for sub-pixel alignments - Drop = open and Roact.createElement(ScrollFrame, { - AnchorPoint = anchor, - Position = UDim2.fromOffset(math.round(pos.x) - 1, posy), - Size = UDim2.fromOffset(math.round(size.x) + 2, scrollHeight), - Layout = { - Padding = UDim.new(0, rowPadding), - }, - }, items), - }), - }) - end - end - - return Roact.createElement("Frame", { - Size = UDim2.new(props.Width, UDim.new(0, props.RowHeightTop)), - Position = props.Position, - AnchorPoint = props.AnchorPoint, - BackgroundTransparency = 1, - LayoutOrder = props.LayoutOrder, - ZIndex = props.ZIndex, - [Roact.Event.InputBegan] = onSelectedInputBegan, - [Roact.Event.InputEnded] = onSelectedInputEnded, - [Roact.Ref] = rootRef.value, - [Roact.Change.AbsolutePosition] = function() - setOpen(false) - end, - [Roact.Change.AbsoluteSize] = function() - setOpen(false) - end, - }, { - Catch = catcher, - Selected = Roact.createElement("TextLabel", { - Size = UDim2.fromScale(1, 1), - BackgroundColor3 = theme:GetColor(background, modifier), - BorderColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Border, modifier), - Text = props.SelectedItem, - Font = Constants.Font, - TextSize = Constants.TextSize, - TextColor3 = theme:GetColor(Enum.StudioStyleGuideColor.MainText, modifier), - TextXAlignment = Enum.TextXAlignment.Left, - TextTruncate = Enum.TextTruncate.AtEnd, - ZIndex = 1, - }, { - Padding = Roact.createElement("UIPadding", { - PaddingLeft = UDim.new(0, TEXT_PADDING_LEFT), - PaddingRight = UDim.new(0, 12), - PaddingBottom = UDim.new(0, 1), - }), - }), - ArrowContainer = Roact.createElement("Frame", { - AnchorPoint = Vector2.new(1, 0), - Position = UDim2.fromScale(1, 0), - Size = UDim2.new(0, 18, 1, 0), - BackgroundTransparency = 1, - ZIndex = 2, - }, { - Arrow = Roact.createElement("ImageLabel", { - Image = "rbxassetid://7260137654", - AnchorPoint = Vector2.new(0.5, 0.5), - Position = UDim2.fromScale(0.5, 0.5), - Size = UDim2.fromOffset(8, 4), - BackgroundTransparency = 1, - ImageColor3 = theme:GetColor(Enum.StudioStyleGuideColor.TitlebarText, modifier), - }), - }), - Children = Roact.createFragment(props[Roact.Children]), - }) -end - -return Hooks.new(Roact)(Dropdown, { - defaultProps = defaultProps, -}) diff --git a/src/Hooks/useFreshCallback.luau b/src/Hooks/useFreshCallback.luau new file mode 100644 index 0000000..1266ed2 --- /dev/null +++ b/src/Hooks/useFreshCallback.luau @@ -0,0 +1,21 @@ +local React = require("@pkg/@jsdotlua/react") + +type Callback = (Args...) -> Rets... + +local function useFreshCallback( + -- stylua: ignore + callback: Callback, + deps: { any }? +): Callback + local ref = React.useRef(callback) :: { current: Callback } + + React.useEffect(function() + ref.current = callback + end, deps) + + return function(...) + return ref.current(...) + end +end + +return useFreshCallback diff --git a/src/Hooks/useMouseDrag.luau b/src/Hooks/useMouseDrag.luau new file mode 100644 index 0000000..2ce2669 --- /dev/null +++ b/src/Hooks/useMouseDrag.luau @@ -0,0 +1,118 @@ +local React = require("@pkg/@jsdotlua/react") + +local useFreshCallback = require("../Hooks/useFreshCallback") + +local function useMouseDrag( + callback: (rbx: GuiObject, input: InputObject) -> (), + deps: { any }?, + onBeganCallback: ((rbx: GuiObject, input: InputObject) -> ())?, -- NB: consumer needs to guard against stale state + onEndedCallback: (() -> ())? +) + local freshCallback = useFreshCallback(callback, deps) + + -- we use a state so consumers can re-render + -- ... as well as a ref so we have an immediately-updated/available value + local holdingState, setHoldingState = React.useState(false) + local holding = React.useRef(false) + + local lastRbx = React.useRef(nil :: GuiObject?) + local moveInput = React.useRef(nil :: InputObject?) + local moveConnection = React.useRef(nil :: RBXScriptConnection?) + + local function runCallback(input: InputObject) + freshCallback(lastRbx.current :: GuiObject, input) + end + + local function connect() + if moveConnection.current then + moveConnection.current:Disconnect() + end + local input = moveInput.current :: InputObject + local signal = input:GetPropertyChangedSignal("Position") + moveConnection.current = signal:Connect(function() + runCallback(input) + end) + runCallback(input) + end + + local function disconnect() + if moveConnection.current then + moveConnection.current:Disconnect() + moveConnection.current = nil + end + if onEndedCallback then + onEndedCallback() + end + end + + -- React.useEffect(function() + -- if moveInput.current then + -- runCallback(moveInput.current) + -- end + -- end, deps) + + React.useEffect(function() + return disconnect + end, {}) + + local function onInputBegan(rbx: GuiObject, input: InputObject) + lastRbx.current = rbx + if input.UserInputType == Enum.UserInputType.MouseMovement then + moveInput.current = input + elseif input.UserInputType == Enum.UserInputType.MouseButton1 then + holding.current = true + setHoldingState(true) + if onBeganCallback then + onBeganCallback(rbx, input) + end + if moveInput.current then + connect() + else + -- case: clicked without move input first + -- this can happen if the instance moves to be under the mouse + runCallback(input) + end + end + end + + local function onInputChanged(rbx: GuiObject, input: InputObject) + lastRbx.current = rbx + if input.UserInputType == Enum.UserInputType.MouseMovement then + moveInput.current = input + if holding.current and not moveConnection.current then + -- handles the case above and connects listener on first move + connect() + end + end + end + + local function onInputEnded(rbx: GuiObject, input: InputObject) + lastRbx.current = rbx + if input.UserInputType == Enum.UserInputType.MouseButton1 then + disconnect() + holding.current = false + setHoldingState(false) + end + end + + local function isActive() + return holdingState == true + end + + local function cancel() + disconnect() + holding.current = false + moveInput.current = nil + setHoldingState(false) + end + + return { + isActive = isActive, + cancel = cancel, + onInputBegan = onInputBegan, + onInputChanged = onInputChanged, + onInputEnded = onInputEnded, + } +end + +return useMouseDrag diff --git a/src/Hooks/useMouseIcon.luau b/src/Hooks/useMouseIcon.luau new file mode 100644 index 0000000..ffdba22 --- /dev/null +++ b/src/Hooks/useMouseIcon.luau @@ -0,0 +1,92 @@ +--[=[ + @class useMouseIcon + + A hook used internally by components for setting and clearing custom mouse icons. To use this + hook, you need to also render a single [PluginProvider] somewhere higher up in the tree. + + To set the mouse icon, use the `setIcon` function and pass an asset url. All components under + the PluginProvider that use this hook share an icon stack; the most recent component to call + `setIcon` will have its icon set as the final mouse icon. Calling `setIcon` twice without + clearing it in between will override the previous icon set by this component. + + Calling `clearIcon` removes the icon set by this component from the stack, which may mean the + mouse icon falls back to the next icon on the stack set by another component. Ensure you call + `clearIcon` on unmount otherwise your icon may never get unset. For example: + + ```lua + local function MyComponent() + local mouseIconApi = useMouseIcon() + + React.useEffect(function() -- clear icon on unmount + return function() + mouseIconApi.clearIcon() + end + end, {}) + + return React.createElement(SomeComponent, { + OnHoverStart = function() + mouseIconApi.setIcon(...) -- some icon for hover + end, + OnHoverEnd = function() + mouseIconApi.clearIcon() + end + }) + end + ``` +]=] + +--[=[ + @within useMouseIcon + @interface mouseIconApi + + @field setIcon (icon: string) -> () + @field getIcon () -> string? + @field clearIcon () -> () +]=] + +local React = require("@pkg/@jsdotlua/react") + +local PluginContext = require("../Contexts/PluginContext") +local useFreshCallback = require("../Hooks/useFreshCallback") + +local function useMouseIcon() + local plugin = React.useContext(PluginContext) + + local lastIconId: string?, setLastIconId = React.useState(nil :: string?) + local lastIconAssetUrl: string?, setLastIconAssetUrl = React.useState(nil :: string?) + + local function getIcon(): string? + return lastIconAssetUrl + end + + local function setIcon(assetUrl: string) + if plugin ~= nil and assetUrl ~= lastIconAssetUrl then + if lastIconId ~= nil then + plugin.popMouseIcon(lastIconId) + end + local newId = plugin.pushMouseIcon(assetUrl) + setLastIconId(newId) + setLastIconAssetUrl(assetUrl) + end + end + + local clearIcon = useFreshCallback(function() + if plugin ~= nil and lastIconId ~= nil then + plugin.popMouseIcon(lastIconId) + setLastIconId(nil) + setLastIconAssetUrl(nil) + end + end, { lastIconId }) + + React.useEffect(function() + return clearIcon + end, {}) + + return { + getIcon = getIcon, + setIcon = setIcon, + clearIcon = clearIcon, + } +end + +return useMouseIcon diff --git a/src/Hooks/useTheme.luau b/src/Hooks/useTheme.luau new file mode 100644 index 0000000..0affba9 --- /dev/null +++ b/src/Hooks/useTheme.luau @@ -0,0 +1,48 @@ +--[=[ + @class useTheme + + A hook used internally by components for reading the selected Studio Theme and thereby visually + theming components appropriately. It is exposed here so that custom components can use this + API to achieve the same effect. Calling the hook returns a [StudioTheme] instance. For example: + + ```lua + local function MyThemedComponent() + local theme = useTheme() + local color = theme:GetColor( + Enum.StudioStyleGuideColor.ScriptBackground, + Enum.StudioStyleGuideModifier.Default + ) + return React.createElement("Frame", { + BackgroundColor3 = color, + ... + }) + end + ``` +]=] + +local Studio = settings().Studio + +local React = require("@pkg/@jsdotlua/react") + +local ThemeContext = require("../Contexts/ThemeContext") + +local function useTheme() + local theme = React.useContext(ThemeContext) + local studioTheme, setStudioTheme = React.useState(Studio.Theme) + + React.useEffect(function() + if theme then + return + end + local connection = Studio.ThemeChanged:Connect(function() + setStudioTheme(Studio.Theme) + end) + return function() + connection:Disconnect() + end + end, { theme }) + + return theme or studioTheme +end + +return useTheme diff --git a/src/Label.lua b/src/Label.lua deleted file mode 100644 index ef182c9..0000000 --- a/src/Label.lua +++ /dev/null @@ -1,41 +0,0 @@ -local Packages = script.Parent.Parent - -local Roact = require(Packages.Roact) -local Hooks = require(Packages.RoactHooks) - -local useTheme = require(script.Parent.useTheme) -local joinDictionaries = require(script.Parent.joinDictionaries) - -local Constants = require(script.Parent.Constants) - -local defaultProps = { - Size = UDim2.fromScale(1, 1), - Text = "Label.defaultProps.Text", - Font = Constants.Font, - TextSize = Constants.TextSize, - TextColorStyle = Enum.StudioStyleGuideColor.MainText, - BackgroundTransparency = 1, - BorderSizePixel = 0, - BorderMode = Enum.BorderMode.Inset, - -- BackColorStyle? - -- BorderColorStyle? -} - -local function Label(props, hooks) - local theme = useTheme(hooks) - - local joinedProps = joinDictionaries(defaultProps, props) - local modifier = Enum.StudioStyleGuideModifier.Default - if joinedProps.Disabled then - modifier = Enum.StudioStyleGuideModifier.Disabled - end - joinedProps.TextColor3 = theme:GetColor(joinedProps.TextColorStyle, modifier) - joinedProps.Disabled = nil - joinedProps.TextColorStyle = nil - - return Roact.createElement("TextLabel", joinedProps) -end - -return Hooks.new(Roact)(Label, { - defaultProps = defaultProps, -}) diff --git a/src/Label.story.lua b/src/Label.story.lua deleted file mode 100644 index a918121..0000000 --- a/src/Label.story.lua +++ /dev/null @@ -1,48 +0,0 @@ -local Packages = script.Parent.Parent -local Roact = require(Packages.Roact) - -local Label = require(script.Parent.Label) - -local textColorItems = {} -for _, colorItem in ipairs(Enum.StudioStyleGuideColor:GetEnumItems()) do - local name = colorItem.Name - if string.sub(name, -4) == "Text" then - table.insert(textColorItems, colorItem) - end -end - -return function(target) - local textElements = {} - for i, colorItem in ipairs(textColorItems) do - local name = colorItem.Name - if colorItem == Enum.StudioStyleGuideColor.MainText then - name ..= " (Default)" - end - textElements[i] = Roact.createElement(Label, { - LayoutOrder = i, - Size = UDim2.fromOffset(120, 16), - Text = name, - TextColorStyle = colorItem, - }) - end - local element = Roact.createFragment({ - Layout = Roact.createElement("UIListLayout", { - Padding = UDim.new(0, 5), - SortOrder = Enum.SortOrder.LayoutOrder, - FillDirection = Enum.FillDirection.Vertical, - HorizontalAlignment = Enum.HorizontalAlignment.Center, - VerticalAlignment = Enum.VerticalAlignment.Center, - }), - Roact.createElement(Label, { - LayoutOrder = 0, - Size = UDim2.fromOffset(120, 32), - Text = "MainText (Disabled)", - Disabled = true, - }), - Roact.createFragment(textElements), - }) - local handle = Roact.mount(element, target) - return function() - Roact.unmount(handle) - end -end diff --git a/src/MainButton.lua b/src/MainButton.lua deleted file mode 100644 index 8020096..0000000 --- a/src/MainButton.lua +++ /dev/null @@ -1,18 +0,0 @@ -local Packages = script.Parent.Parent -local Roact = require(Packages.Roact) - -local joinDictionaries = require(script.Parent.joinDictionaries) -local BaseButton = require(script.Parent.BaseButton) - -local function MainButton(props) - return Roact.createElement( - BaseButton, - joinDictionaries({ - TextColorStyle = Enum.StudioStyleGuideColor.DialogMainButtonText, - BackgroundColorStyle = Enum.StudioStyleGuideColor.DialogMainButton, - BorderColorStyle = Enum.StudioStyleGuideColor.ButtonBorder, - }, props) - ) -end - -return MainButton diff --git a/src/MainButton.story.lua b/src/MainButton.story.lua deleted file mode 100644 index 1c9bfc4..0000000 --- a/src/MainButton.story.lua +++ /dev/null @@ -1,39 +0,0 @@ -local Packages = script.Parent.Parent -local Roact = require(Packages.Roact) - -local MainButton = require(script.Parent.MainButton) - -return function(target) - local element = Roact.createFragment({ - Layout = Roact.createElement("UIListLayout", { - Padding = UDim.new(0, 5), - SortOrder = Enum.SortOrder.LayoutOrder, - FillDirection = Enum.FillDirection.Vertical, - HorizontalAlignment = Enum.HorizontalAlignment.Center, - VerticalAlignment = Enum.VerticalAlignment.Center, - }), - Button0 = Roact.createElement(MainButton, { - LayoutOrder = 0, - Size = UDim2.fromOffset(100, 32), - Text = "Enabled", - OnActivated = function() end, - }), - Button1 = Roact.createElement(MainButton, { - LayoutOrder = 1, - Size = UDim2.fromOffset(100, 32), - Text = "Selected", - Selected = true, - OnActivated = function() end, - }), - Button2 = Roact.createElement(MainButton, { - LayoutOrder = 2, - Size = UDim2.fromOffset(100, 32), - Text = "Disabled", - Disabled = true, - }), - }) - local handle = Roact.mount(element, target) - return function() - Roact.unmount(handle) - end -end diff --git a/src/PluginContext.lua b/src/PluginContext.lua deleted file mode 100644 index d3f611d..0000000 --- a/src/PluginContext.lua +++ /dev/null @@ -1,4 +0,0 @@ -local Packages = script.Parent.Parent -local Roact = require(Packages.Roact) - -return Roact.createContext() diff --git a/src/PluginProvider.lua b/src/PluginProvider.lua deleted file mode 100644 index 12ecdde..0000000 --- a/src/PluginProvider.lua +++ /dev/null @@ -1,47 +0,0 @@ -local HttpService = game:GetService("HttpService") - -local Packages = script.Parent.Parent - -local Roact = require(Packages.Roact) -local Hooks = require(Packages.RoactHooks) - -local PluginContext = require(script.Parent.PluginContext) - --- consumers that set mouse icon are responsible for unsetting on unmount - -local function PluginProvider(props, hooks) - local plugin = props.Plugin - local iconStack = hooks.useValue({}) - - local function updateMouseIcon() - local top = iconStack.value[#iconStack.value] - plugin:GetMouse().Icon = if top then top.icon else "" - end - - local function pushMouseIcon(icon) - local id = HttpService:GenerateGUID(false) - table.insert(iconStack.value, { id = id, icon = icon }) - updateMouseIcon() - return id - end - - local function popMouseIcon(id) - for i = #iconStack.value, 1, -1 do - local item = iconStack.value[i] - if item.id == id then - table.remove(iconStack.value, i) - end - end - updateMouseIcon() - end - - return Roact.createElement(PluginContext.Provider, { - value = { - plugin = plugin, - pushMouseIcon = pushMouseIcon, - popMouseIcon = popMouseIcon, - }, - }, props[Roact.Children]) -end - -return Hooks.new(Roact)(PluginProvider) diff --git a/src/RadioButton.lua b/src/RadioButton.lua deleted file mode 100644 index ade6100..0000000 --- a/src/RadioButton.lua +++ /dev/null @@ -1,108 +0,0 @@ -local TextService = game:GetService("TextService") - -local Packages = script.Parent.Parent - -local Roact = require(Packages.Roact) -local Hooks = require(Packages.RoactHooks) - -local Constants = require(script.Parent.Constants) -local useTheme = require(script.Parent.useTheme) - -local defaultProps = { - Label = "RadioButton.defaultProps.Label", -} - -local HEIGHT = 16 -local TEXT_PADDING = 5 - -local FONT = Constants.Font -local TEXT_SIZE = Constants.TextSize - -local function RadioButton(props, hooks) - local theme = useTheme(hooks) - local hovered, setHovered = hooks.useState(false) - - local modifier = Enum.StudioStyleGuideModifier.Default - if props.Disabled then - modifier = Enum.StudioStyleGuideModifier.Disabled - elseif hovered then - modifier = Enum.StudioStyleGuideModifier.Hover - end - - local secondaryColorStyle = Enum.StudioStyleGuideColor.DimmedText - if props.Value == true then - secondaryColorStyle = Enum.StudioStyleGuideColor.SubText - end - - local textSize = TextService:GetTextSize(props.Label, TEXT_SIZE, FONT, Vector2.new(math.huge, math.huge)) - - return Roact.createElement("TextButton", { - Active = true, - Text = "", - AnchorPoint = Vector2.new(0.5, 0.5), - Position = UDim2.fromScale(0.5, 0.5), - Size = UDim2.fromOffset(textSize.X + HEIGHT + TEXT_PADDING, HEIGHT), - BackgroundTransparency = 1, - LayoutOrder = props.LayoutOrder, - ZIndex = props.ZIndex, - [Roact.Event.InputBegan] = function(_, input) - if not props.Disabled and input.UserInputType == Enum.UserInputType.MouseMovement then - setHovered(true) - end - end, - [Roact.Event.InputEnded] = function(_, input) - if not props.Disabled and input.UserInputType == Enum.UserInputType.MouseMovement then - setHovered(false) - end - end, - [Roact.Event.Activated] = function() - if not props.Disabled then - props.OnActivated() - end - end, - }, { - Main = Roact.createElement("Frame", { - Size = UDim2.fromOffset(HEIGHT - 4, HEIGHT - 4), - Position = UDim2.fromOffset(2, 2), - BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Button, modifier), - BackgroundTransparency = props.Disabled and 0.65 or 0, - }, { - Corner = Roact.createElement("UICorner", { - CornerRadius = UDim.new(0.5, 0), - }), - Stroke = Roact.createElement("UIStroke", { - Color = theme:GetColor(secondaryColorStyle, modifier), - Thickness = 1, - Transparency = props.Disabled and 0.65 or 0, - }), - Inner = props.Value == true and Roact.createElement("Frame", { - Size = UDim2.new(1, -4, 1, -4), - Position = UDim2.fromOffset(2, 2), - BackgroundColor3 = theme:GetColor(secondaryColorStyle, modifier), - BackgroundTransparency = props.Disabled and 0.65 or 0, - BorderSizePixel = 0, - }, { - Corner = Roact.createElement("UICorner", { - CornerRadius = UDim.new(0.5, 0), - }), - }), - }), - Label = Roact.createElement("TextLabel", { - BackgroundTransparency = 1, - Size = UDim2.new(0, textSize.X, 1, 0), - Position = UDim2.new(0, HEIGHT + TEXT_PADDING, 0, 0), - TextXAlignment = Enum.TextXAlignment.Left, - TextYAlignment = Enum.TextYAlignment.Center, - Text = props.Label, - TextTruncate = Enum.TextTruncate.AtEnd, - Font = FONT, - TextSize = TEXT_SIZE, - TextColor3 = theme:GetColor(Enum.StudioStyleGuideColor.MainText, modifier), - }), - Children = Roact.createFragment(props[Roact.Children]), - }) -end - -return Hooks.new(Roact)(RadioButton, { - defaultProps = defaultProps, -}) diff --git a/src/RadioButton.story.lua b/src/RadioButton.story.lua deleted file mode 100644 index 8272d35..0000000 --- a/src/RadioButton.story.lua +++ /dev/null @@ -1,48 +0,0 @@ -local Packages = script.Parent.Parent -local Roact = require(Packages.Roact) - -local RadioButton = require(script.Parent.RadioButton) - -local Wrapper = Roact.Component:extend("Wrapper") - -function Wrapper:init() - self:setState({ - Selected = 1, - }) -end - -function Wrapper:render() - local count = 3 - - local buttons = {} - for i = 1, count do - buttons[i] = Roact.createElement(RadioButton, { - LayoutOrder = i, - Value = self.state.Selected == i, - Label = "Button" .. tostring(i), - Disabled = i == count, - OnActivated = function() - self:setState({ Selected = i }) - end, - }) - end - - return Roact.createFragment({ - Layout = Roact.createElement("UIListLayout", { - Padding = UDim.new(0, 5), - SortOrder = Enum.SortOrder.LayoutOrder, - FillDirection = Enum.FillDirection.Vertical, - HorizontalAlignment = Enum.HorizontalAlignment.Center, - VerticalAlignment = Enum.VerticalAlignment.Center, - }), - Buttons = Roact.createFragment(buttons), - }) -end - -return function(target) - local element = Roact.createElement(Wrapper) - local handle = Roact.mount(element, target) - return function() - Roact.unmount(handle) - end -end diff --git a/src/ScrollFrame.story.lua b/src/ScrollFrame.story.lua deleted file mode 100644 index ffa9a91..0000000 --- a/src/ScrollFrame.story.lua +++ /dev/null @@ -1,130 +0,0 @@ -local Packages = script.Parent.Parent -local Roact = require(Packages.Roact) - -local Label = require(script.Parent.Label) -local Checkbox = require(script.Parent.Checkbox) -local ScrollFrame = require(script.Parent.ScrollFrame) - -local numRows = 16 -local numCols = 16 - -local size = Vector2.new(48, 32) -local fmt = "%i,%i" - -local function Row(props) - local children = {} - for i = 1, numCols do - children[i] = Roact.createElement(Label, { - LayoutOrder = i, - Text = string.format(fmt, i - 1, props.Row - 1), - Size = UDim2.new(0, size.x, 1, 0), - BorderSizePixel = 0, - BackgroundTransparency = 0, - BackgroundColor3 = Color3.fromHSV((i + props.Row) % 4 * 0.25, 0.7, 0.6), - }) - end - return Roact.createElement("Frame", { - LayoutOrder = props.Row, - Size = UDim2.fromOffset(numCols * size.x, size.y), - BackgroundTransparency = 1, - }, { - Layout = Roact.createElement("UIListLayout", { - FillDirection = Enum.FillDirection.Horizontal, - SortOrder = Enum.SortOrder.LayoutOrder, - }), - Children = Roact.createFragment(children), - }) -end - -local Wrapper = Roact.Component:extend("ScrollFrameWrapper") - -function Wrapper:init() - self:setState({ - ModeX = true, - ModeY = true, - Enabled = true, - }) -end - -function Wrapper:render() - local rows = {} - for i = 1, numRows do - rows[i] = Roact.createElement(Row, { Row = i }) - end - - local mode = Enum.ScrollingDirection.XY - if not self.state.ModeX then - mode = Enum.ScrollingDirection.Y - elseif not self.state.ModeY then - mode = Enum.ScrollingDirection.X - end - - return Roact.createElement("Frame", { - Active = true, - AnchorPoint = Vector2.new(0.5, 0.5), - Position = UDim2.fromScale(0.5, 0.5), - Size = UDim2.fromScale(0.5, 0.5), - BackgroundTransparency = 1, - }, { - Layout = Roact.createElement("UIListLayout", { - FillDirection = Enum.FillDirection.Vertical, - SortOrder = Enum.SortOrder.LayoutOrder, - VerticalAlignment = Enum.VerticalAlignment.Center, - Padding = UDim.new(0, 5), - }), - CheckboxEnabled = Roact.createElement(Checkbox, { - LayoutOrder = 0, - Value = self.state.Enabled, - Label = "Enabled", - OnActivated = function() - self:setState({ Enabled = not self.state.Enabled }) - end, - }), - CheckboxX = Roact.createElement(Checkbox, { - LayoutOrder = 1, - Value = self.state.ModeX, - Label = "X Direction", - OnActivated = function() - local nextX = not self.state.ModeX - local nextY = self.state.ModeY - if nextX == false and nextY == false then - nextY = true - end - self:setState({ - ModeX = nextX, - ModeY = nextY, - }) - end, - }), - CheckboxY = Roact.createElement(Checkbox, { - LayoutOrder = 2, - Value = self.state.ModeY, - Label = "Y Direction", - OnActivated = function() - local nextY = not self.state.ModeY - local nextX = self.state.ModeX - if nextY == false and nextX == false then - nextX = true - end - self:setState({ - ModeX = nextX, - ModeY = nextY, - }) - end, - }), - Main = mode and Roact.createElement(ScrollFrame, { - LayoutOrder = 3, - Size = UDim2.new(1, 0, 0, 240), - ScrollingDirection = mode, - Disabled = not self.state.Enabled, - }, rows), - }) -end - -return function(target) - local element = Roact.createElement(Wrapper) - local handle = Roact.mount(element, target) - return function() - Roact.unmount(handle) - end -end diff --git a/src/ScrollFrame/Constants.lua b/src/ScrollFrame/Constants.lua deleted file mode 100644 index 931b812..0000000 --- a/src/ScrollFrame/Constants.lua +++ /dev/null @@ -1,4 +0,0 @@ -return { - ScrollBarSize = 16, - ScrollStep = 70, -} diff --git a/src/ScrollFrame/ScrollArrow.lua b/src/ScrollFrame/ScrollArrow.lua deleted file mode 100644 index bee52a1..0000000 --- a/src/ScrollFrame/ScrollArrow.lua +++ /dev/null @@ -1,122 +0,0 @@ -local Packages = script.Parent.Parent.Parent -local Roact = require(Packages.Roact) - -local RunService = game:GetService("RunService") - -local joinDictionaries = require(script.Parent.Parent.joinDictionaries) -local withTheme = require(script.Parent.Parent.withTheme) - -local ScrollArrow = Roact.Component:extend("ScrollArrow") - -local ScrollConstants = require(script.Parent.Constants) - -local ARROW_IMAGE = "rbxassetid://6677623152" -local BAR_SIZE = ScrollConstants.ScrollBarSize - -ScrollArrow.Direction = { - Up = "Up", - Down = "Down", - Left = "Left", - Right = "Right", -} - -function ScrollArrow:init() - self:setState({ - Hover = false, - Pressed = false, - }) - self.listenConnection = nil - - self.onInputBegan = function(_, inputObject) - if inputObject.UserInputType == Enum.UserInputType.MouseButton1 then - self:setState({ Pressed = true }) - self.props.OnActivated() - self:connect() - elseif inputObject.UserInputType == Enum.UserInputType.MouseMovement then - self:setState({ Hover = true }) - end - end - - self.onInputEnded = function(_, inputObject) - if inputObject.UserInputType == Enum.UserInputType.MouseButton1 then - self:setState({ Pressed = false }) - self:disconnect() - elseif inputObject.UserInputType == Enum.UserInputType.MouseMovement then - self:setState({ Hover = false }) - end - end -end - -function ScrollArrow:willUnmount() - self:disconnect() -end - -function ScrollArrow:connect() - self:disconnect() - local nextAt = os.clock() + 0.35 - self.listenConnection = RunService.Heartbeat:Connect(function() - local now = os.clock() - if now >= nextAt then - if self.state.Hover then - self.props.OnActivated() - end - nextAt += 0.05 - end - end) -end - -function ScrollArrow:disconnect() - if self.listenConnection then - self.listenConnection:Disconnect() - self.listenConnection = nil - end -end - -function ScrollArrow:render() - local modifier = Enum.StudioStyleGuideModifier.Default - if self.props.Disabled then - modifier = Enum.StudioStyleGuideModifier.Disabled - elseif self.state.Pressed then - modifier = Enum.StudioStyleGuideModifier.Pressed - end - - local anchor = Vector2.new(0, 0) - local position = UDim2.fromScale(0, 0) - local imageOffset = Vector2.new(0, 0) - if self.props.Direction == ScrollArrow.Direction.Down then - anchor = Vector2.new(0, 1) - position = UDim2.fromScale(0, 1) - imageOffset = Vector2.new(0, BAR_SIZE) - elseif self.props.Direction == ScrollArrow.Direction.Left then - imageOffset = Vector2.new(BAR_SIZE, 0) - elseif self.props.Direction == ScrollArrow.Direction.Right then - anchor = Vector2.new(1, 0) - position = UDim2.fromScale(1, 0) - imageOffset = Vector2.new(BAR_SIZE, BAR_SIZE) - end - - return withTheme(function(theme) - local baseProps = { - AnchorPoint = anchor, - Position = position, - Size = UDim2.fromOffset(BAR_SIZE, BAR_SIZE), - Image = ARROW_IMAGE, - ImageRectSize = Vector2.new(BAR_SIZE, BAR_SIZE), - ImageRectOffset = imageOffset, - ImageColor3 = theme:GetColor(Enum.StudioStyleGuideColor.TitlebarText, modifier), - BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.ScrollBar, modifier), - BorderColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Border, modifier), - } - return self.props.Disabled and Roact.createElement("ImageLabel", baseProps) - or Roact.createElement( - "ImageButton", - joinDictionaries(baseProps, { - AutoButtonColor = false, - [Roact.Event.InputBegan] = self.onInputBegan, - [Roact.Event.InputEnded] = self.onInputEnded, - }) - ) - end) -end - -return ScrollArrow diff --git a/src/ScrollFrame/ScrollBarHandle.lua b/src/ScrollFrame/ScrollBarHandle.lua deleted file mode 100644 index ba02ff1..0000000 --- a/src/ScrollFrame/ScrollBarHandle.lua +++ /dev/null @@ -1,95 +0,0 @@ -local Packages = script.Parent.Parent.Parent -local Roact = require(Packages.Roact) - -local withTheme = require(script.Parent.Parent.withTheme) - -local ScrollBarHandle = Roact.Component:extend("ScrollBarHandle") - -function ScrollBarHandle:init() - self:setState({ - Dragging = false, - Hover = false, - }) - self._dragBegin = nil - self._connection = nil - - self.onInputBegan = function(_, inputObject) - if self.props.Disabled then - return - end - local t = inputObject.UserInputType - if t == Enum.UserInputType.MouseMovement then - self:setState({ Hover = true }) - elseif t == Enum.UserInputType.MouseButton1 and not self.state.Dragging then - self:setState({ Dragging = true }) - self._dragBegin = inputObject.Position - self.props.OnDragBegan() - end - end - - self.onInputEnded = function(_, inputObject) - if self.props.Disabled then - return - end - local t = inputObject.UserInputType - if t == Enum.UserInputType.MouseMovement then - self:setState({ Hover = false }) - elseif t == Enum.UserInputType.MouseButton1 and self.state.Dragging then - self.props.OnDragEnded() - self._dragBegin = nil - self:setState({ Dragging = false }) - self:disconnect() - end - end - - self.onInputChanged = function(_, inputObject) - if self.props.Disabled then - return - elseif not self.state.Dragging or self._connection then - return - elseif inputObject.UserInputType ~= Enum.UserInputType.MouseMovement then - return - end - local signal = inputObject:GetPropertyChangedSignal("Position") - self._connection = signal:Connect(function() - local diff = inputObject.Position - self._dragBegin - self.props.OnDragChanged(Vector2.new(diff.x, diff.y)) - end) - end -end - -function ScrollBarHandle:disconnect() - if self._connection then - self._connection:Disconnect() - self._connection = nil - end -end - -function ScrollBarHandle:willUnmount() - self:disconnect() -end - -function ScrollBarHandle:render() - local modifier = Enum.StudioStyleGuideModifier.Default - if self.props.Disabled then - modifier = Enum.StudioStyleGuideModifier.Disabled - elseif self.state.Dragging or self.state.Hover then - modifier = Enum.StudioStyleGuideModifier.Pressed - end - return withTheme(function(theme) - return Roact.createElement("TextButton", { - AutoButtonColor = false, - AnchorPoint = self.props.AnchorPoint, - Position = self.props.Position, - Size = self.props.Size, - BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.ScrollBar, modifier), - BorderColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Border), - Text = "", - [Roact.Event.InputBegan] = self.onInputBegan, - [Roact.Event.InputChanged] = self.onInputChanged, - [Roact.Event.InputEnded] = self.onInputEnded, - }) - end) -end - -return ScrollBarHandle diff --git a/src/ScrollFrame/init.lua b/src/ScrollFrame/init.lua deleted file mode 100644 index a9bb393..0000000 --- a/src/ScrollFrame/init.lua +++ /dev/null @@ -1,338 +0,0 @@ -local Packages = script.Parent.Parent -local Roact = require(Packages.Roact) - -local withTheme = require(script.Parent.withTheme) -local joinDictionaries = require(script.Parent.joinDictionaries) - -local ScrollFrame = Roact.Component:extend("ScrollFrame") - -local ScrollArrow = require(script.ScrollArrow) -local ScrollBarHandle = require(script.ScrollBarHandle) - -local ScrollConstants = require(script.Constants) -local BAR_SIZE = ScrollConstants.ScrollBarSize -local SCROLL_STEP = ScrollConstants.ScrollStep - -local defaultLayout = { - ClassName = "UIListLayout", - SortOrder = Enum.SortOrder.LayoutOrder, -} - -ScrollFrame.defaultProps = { - ScrollingDirection = Enum.ScrollingDirection.Y, - BorderSizePixel = 1, - LayoutOrder = 0, - ZIndex = 0, - Disabled = false, - OnScrolled = function() end, - Layout = defaultLayout, -} - -local function maxVector(vec, limit) - return Vector2.new(math.max(vec.x, limit.x), math.max(vec.y, limit.y)) -end - -local function clampVector(vec, min, max) - return Vector2.new(math.clamp(vec.x, min.x, max.x), math.clamp(vec.y, min.y, max.y)) -end - -function ScrollFrame:init() - self.scrollFrameRef = Roact.createRef() - - self.windowSize, self.setWindowSize = Roact.createBinding(Vector2.new(0, 0)) - self.contentSize, self.setContentSize = Roact.createBinding(Vector2.new(0, 0)) - - local canvasPosition, setCanvasPosition = Roact.createBinding(Vector2.new(0, 0)) - self.canvasPosition = canvasPosition - self.setCanvasPosition = function(pos) - self.props.OnScrolled(pos) - setCanvasPosition(pos) - end - - self.barPosScale = Roact.joinBindings({ - windowSize = self.windowSize, - contentSize = self.contentSize, - canvasPosition = self.canvasPosition, - }):map(function(data) - local windowSize = self:getInnerSize() - local region = data.contentSize - windowSize - return Vector2.new( - region.x > 0 and data.canvasPosition.x / region.x or 0, - region.y > 0 and data.canvasPosition.y / region.y or 0 - ) - end) - - self.barSizeScale = Roact.joinBindings({ - windowSize = self.windowSize, - contentSize = self.contentSize, - }):map(function(data) - local contentSize = data.contentSize - local windowSize = self:getInnerSize() - local region = contentSize - windowSize - return Vector2.new( - region.x > 0 and windowSize.x / contentSize.x or 0, - region.y > 0 and windowSize.y / contentSize.y or 0 - ) - end) - - self.barVisible = self.barSizeScale:map(function(size) - local direction = self.props.ScrollingDirection - local hasX = direction ~= Enum.ScrollingDirection.Y - local hasY = direction ~= Enum.ScrollingDirection.X - return { - x = hasX and size.x > 0 and size.x < 1, - y = hasY and size.y > 0 and size.y < 1, - } - end) - - self.maybeScrollInput = function(_, inputObject) - if self.props.Disabled then - return - elseif inputObject.UserInputType == Enum.UserInputType.MouseWheel then - local factor = -inputObject.Position.z - local visible = self.barVisible:getValue() - if visible.y then - self:scroll(Vector2.new(0, factor)) - elseif visible.x then - self:scroll(Vector2.new(factor, 0)) - end - end - end - - self.onDragBegan = function() - self._dragBegin = self.canvasPosition:getValue() - end - - self.onDragEnded = function() - self._dragBegin = nil - end - - self.onDragChanged = function(amount) - local windowSize = self:getInnerSize() - local contentSize = self.contentSize:getValue() - local region = maxVector(contentSize - windowSize, Vector2.new(0, 0)) - local barAreaSize = windowSize - 2 * Vector2.new(BAR_SIZE, BAR_SIZE) -- buttons - local alpha = amount / barAreaSize - local pos = self._dragBegin + alpha * contentSize - self.setCanvasPosition(clampVector(pos, Vector2.new(0, 0), region)) - end -end - -function ScrollFrame:getInnerSize() - local direction = self.props.ScrollingDirection - local hasX = direction ~= Enum.ScrollingDirection.Y - local hasY = direction ~= Enum.ScrollingDirection.X - local windowSize = self.windowSize:getValue() - local windowSizeWithBars = windowSize - Vector2.new(BAR_SIZE + 1, BAR_SIZE + 1) - local contentSize = self.contentSize:getValue() - local barVisible = { - x = hasX and contentSize.x > windowSizeWithBars.x, - y = hasY and contentSize.y > windowSizeWithBars.y, - } - local sizeX = windowSize.x - (barVisible.y and BAR_SIZE + 1 or 0) -- +1 for inner bar border - local sizeY = windowSize.y - (barVisible.x and BAR_SIZE + 1 or 0) -- as above - return maxVector(Vector2.new(sizeX, sizeY), Vector2.new(0, 0)) -end - -function ScrollFrame:scroll(dir) - local contentSize = self.contentSize:getValue() - local windowSize = self:getInnerSize() - local max = maxVector(contentSize - windowSize, Vector2.new(0, 0)) - local current = self.canvasPosition:getValue() - local amount = dir * SCROLL_STEP - self.setCanvasPosition(clampVector(current + amount, Vector2.new(0, 0), max)) -end - -function ScrollFrame:refreshCanvasPosition() - local contentSize = self.contentSize:getValue() - local windowSize = self:getInnerSize() - local max = maxVector(contentSize - windowSize, Vector2.new(0, 0)) - local current = self.canvasPosition:getValue() - local target = clampVector(current, Vector2.new(0, 0), max) - self.setCanvasPosition(target) -end - -function ScrollFrame:didUpdate(prevProps) - if prevProps.ScrollingDirection ~= self.props.ScrollingDirection then - self:refreshCanvasPosition() - end -end - -function ScrollFrame:render() - local modifier = Enum.StudioStyleGuideModifier.Default - if self.props.Disabled then - modifier = Enum.StudioStyleGuideModifier.Disabled - end - - local layoutProps = joinDictionaries(defaultLayout, self.props.Layout) - local layoutClass = layoutProps.ClassName - layoutProps.ClassName = nil - layoutProps[Roact.Change.AbsoluteContentSize] = function(rbx) - self.setContentSize(rbx.AbsoluteContentSize) - self:refreshCanvasPosition() - end - - return withTheme(function(theme) - return Roact.createElement("Frame", { - LayoutOrder = self.props.LayoutOrder, - ZIndex = self.props.ZIndex, - AnchorPoint = self.props.AnchorPoint, - Position = self.props.Position, - Size = self.props.Size, - BorderMode = Enum.BorderMode.Inset, - BorderSizePixel = self.props.BorderSizePixel, - BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.MainBackground, modifier), - BorderColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Border, modifier), - [Roact.Change.AbsoluteSize] = function(rbx) - local border = self.props.BorderSizePixel * Vector2.new(2, 2) -- each border - self.setWindowSize(rbx.AbsoluteSize - border) - self:refreshCanvasPosition() - end, - [Roact.Event.InputBegan] = self.maybeScrollInput, - [Roact.Event.InputChanged] = self.maybeScrollInput, - }, { - Cover = self.props.Disabled and Roact.createElement("Frame", { - ZIndex = 1, - Size = self.barVisible:map(function(visible) - return UDim2.new( - UDim.new(1, visible.y and -BAR_SIZE or 0), - UDim.new(1, visible.x and -BAR_SIZE or 0) - ) - end), - BorderSizePixel = 0, - BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.MainBackground), - BackgroundTransparency = 0.25, - }), - Clipping = Roact.createElement("Frame", { - ZIndex = 0, - Size = self.barVisible:map(function(visible) - return UDim2.new( - UDim.new(1, visible.y and -BAR_SIZE or 0), - UDim.new(1, visible.x and -BAR_SIZE or 0) - ) - end), - BackgroundTransparency = 1, - ClipsDescendants = true, - }, { - Holder = Roact.createElement("Frame", { - BackgroundTransparency = 1, - Size = UDim2.fromScale(1, 1), - Position = self.canvasPosition:map(function(pos) - return UDim2.fromOffset(-pos.x, -pos.y) - end), - }, { - Layout = Roact.createElement(layoutClass, layoutProps), - Content = Roact.createFragment(self.props[Roact.Children]), - }), - }), - BarVertical = Roact.createElement("Frame", { - ZIndex = 2, - AnchorPoint = Vector2.new(1, 0), - Position = UDim2.fromScale(1, 0), - BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.ScrollBarBackground, modifier), - BorderColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Border, modifier), - BorderSizePixel = 1, - Size = self.barVisible:map(function(visible) - local shift = visible.x and (-BAR_SIZE - 1) or 0 - return UDim2.new(0, BAR_SIZE, 1, shift) - end), - Visible = self.barVisible:map(function(visible) - return visible.y - end), - }, { - UpArrow = Roact.createElement(ScrollArrow, { - Disabled = self.props.Disabled, - Direction = ScrollArrow.Direction.Up, - OnActivated = function() - self:scroll(Vector2.new(0, -0.25)) - end, - }), - DownArrow = Roact.createElement(ScrollArrow, { - Disabled = self.props.Disabled, - Direction = ScrollArrow.Direction.Down, - OnActivated = function() - self:scroll(Vector2.new(0, 0.25)) - end, - }), - BarBackground = Roact.createElement("Frame", { - Position = UDim2.fromOffset(0, BAR_SIZE + 1), - Size = UDim2.new(1, 0, 1, -BAR_SIZE * 2 - 2), - BackgroundTransparency = 1, - }, { - Bar = Roact.createElement(ScrollBarHandle, { - Disabled = self.props.Disabled, - Position = self.barPosScale:map(function(scale) - return UDim2.fromScale(0, scale.y) - end), - AnchorPoint = self.barPosScale:map(function(scale) - return Vector2.new(0, scale.y) - end), - Size = self.barSizeScale:map(function(scale) - return UDim2.fromScale(1, scale.y) - end), - OnDragBegan = self.onDragBegan, - OnDragEnded = self.onDragEnded, - OnDragChanged = function(amount) - self.onDragChanged(amount * Vector2.new(0, 1)) - end, - }), - }), - }), - BarHorizontal = Roact.createElement("Frame", { - ZIndex = 2, - AnchorPoint = Vector2.new(0, 1), - Position = UDim2.fromScale(0, 1), - BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.ScrollBarBackground, modifier), - BorderColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Border, modifier), - BorderSizePixel = 1, - Size = self.barVisible:map(function(visible) - local shift = visible.y and (-BAR_SIZE - 1) or 0 - return UDim2.new(1, shift, 0, BAR_SIZE) - end), - Visible = self.barVisible:map(function(visible) - return visible.x - end), - }, { - LeftArrow = Roact.createElement(ScrollArrow, { - Disabled = self.props.Disabled, - Direction = ScrollArrow.Direction.Left, - OnActivated = function() - self:scroll(Vector2.new(-0.25, 0)) - end, - }), - RightArrow = Roact.createElement(ScrollArrow, { - Disabled = self.props.Disabled, - Direction = ScrollArrow.Direction.Right, - OnActivated = function() - self:scroll(Vector2.new(0.25, 0)) - end, - }), - BarBackground = Roact.createElement("Frame", { - Position = UDim2.fromOffset(BAR_SIZE + 1, 0), - Size = UDim2.new(1, -BAR_SIZE * 2 - 2, 1, 0), - BackgroundTransparency = 1, - }, { - Bar = Roact.createElement(ScrollBarHandle, { - Disabled = self.props.Disabled, - Position = self.barPosScale:map(function(scale) - return UDim2.fromScale(scale.x, 0) - end), - AnchorPoint = self.barPosScale:map(function(scale) - return Vector2.new(scale.x, 0) - end), - Size = self.barSizeScale:map(function(scale) - return UDim2.fromScale(scale.x, 1) - end), - OnDragBegan = self.onDragBegan, - OnDragEnded = self.onDragEnded, - OnDragChanged = function(amount) - self.onDragChanged(amount * Vector2.new(1, 0)) - end, - }), - }), - }), - }) - end) -end - -return ScrollFrame diff --git a/src/Slider.lua b/src/Slider.lua deleted file mode 100644 index 7d77235..0000000 --- a/src/Slider.lua +++ /dev/null @@ -1,136 +0,0 @@ -local Packages = script.Parent.Parent - -local Roact = require(Packages.Roact) -local Hooks = require(Packages.RoactHooks) - -local useTheme = require(script.Parent.useTheme) -local useDragInput = require(script.Parent.useDragInput) - -local PADDING_BAR_SIDE = 3 -local PADDING_REGION_TOP = 1 -local PADDING_REGION_SIDE = 6 - -local defaultBackground = Hooks.new(Roact)(function(props, hooks) - local theme = useTheme(hooks) - local mainModifier = Enum.StudioStyleGuideModifier.Default - if props.Disabled then - mainModifier = Enum.StudioStyleGuideModifier.Disabled - end - return Roact.createElement("Frame", { - BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.InputFieldBackground, mainModifier), - Size = UDim2.fromScale(1, 1), - BorderSizePixel = 0, - }) -end) - -local defaultProps = { - Step = 0, - Disabled = false, - Background = defaultBackground, -} - -local function Slider(props, hooks) - local theme = useTheme(hooks) - - local regionRef = hooks.useValue(Roact.createRef()) - - local drag = useDragInput(hooks, function(_, position) - local range = props.Max - props.Min - local region = regionRef.value:getValue() - local offset = position.x - region.AbsolutePosition.x - local alpha = offset / region.AbsoluteSize.x - - local value = range * alpha - if props.Step > 0 then - value = math.round(value / props.Step) * props.Step - end - value = math.clamp(value, 0, range) + props.Min - - if value ~= props.Value then - props.OnChange(value) - end - end) - - hooks.useEffect(function() - if props.Disabled then - drag.cancel() - end - end, { props.Disabled }) - - local range = props.Max - props.Min - local alpha = (props.Value - props.Min) / range - - local handleModifier = Enum.StudioStyleGuideModifier.Default - if props.Disabled then - handleModifier = Enum.StudioStyleGuideModifier.Disabled - elseif drag.hovered or drag.active then - handleModifier = Enum.StudioStyleGuideModifier.Hover - end - - -- used to create a blended border color when slider is disabled - local handleFill = theme:GetColor(Enum.StudioStyleGuideColor.Button, handleModifier) - local handleBorder = theme:GetColor(Enum.StudioStyleGuideColor.Border, handleModifier) - - -- if we use a Frame here, the 2d studio selection rectangle will appear when dragging - -- we could prevent that using Active = true, but that displays the Click cursor - -- ... the best workaround is a TextButton with Active = false - return Roact.createElement("TextButton", { - Text = "", - Active = false, - AutoButtonColor = false, - Size = UDim2.new(1, 0, 0, 22), - Position = props.Position, - AnchorPoint = props.AnchorPoint, - LayoutOrder = props.LayoutOrder, - ZIndex = props.ZIndex, - BackgroundTransparency = 1, - [Roact.Event.InputBegan] = if not props.Disabled then drag.onInputBegan else nil, - [Roact.Event.InputEnded] = if not props.Disabled then drag.onInputEnded else nil, - }, { - BackgroundHolder = Roact.createElement("Frame", { - ZIndex = 0, - Size = UDim2.fromScale(1, 1), - BackgroundTransparency = 1, - }, { - Background = Roact.createElement(props.Background, { - Disabled = props.Disabled, - Hover = drag.hovered, - Dragging = drag.active, - Value = props.Value, - }), - }), - Bar = Roact.createElement("Frame", { - ZIndex = 1, - Position = UDim2.fromOffset(PADDING_BAR_SIDE, 10), - Size = UDim2.new(1, -PADDING_BAR_SIDE * 2, 0, 2), - BorderSizePixel = 0, - BackgroundTransparency = props.Disabled and 0.4 or 0, - BackgroundColor3 = theme:GetColor( - -- this looks odd but provides the correct colors for both themes - Enum.StudioStyleGuideColor.TitlebarText, - Enum.StudioStyleGuideModifier.Disabled - ), - }), - HandleRegion = Roact.createElement("Frame", { - ZIndex = 2, - Position = UDim2.fromOffset(PADDING_REGION_SIDE, PADDING_REGION_TOP), - Size = UDim2.new(1, -PADDING_REGION_SIDE * 2, 1, -PADDING_REGION_TOP * 2), - BackgroundTransparency = 1, - [Roact.Ref] = regionRef.value, - }, { - Handle = Roact.createElement("Frame", { - AnchorPoint = Vector2.new(0.5, 0), - Position = UDim2.fromScale(alpha, 0), - Size = UDim2.new(0, 10, 1, 0), - BorderMode = Enum.BorderMode.Inset, - BorderSizePixel = 1, - BorderColor3 = handleBorder:Lerp(handleFill, props.Disabled and 0.5 or 0), - BackgroundColor3 = handleFill, - }), - }), - }) -end - -return Hooks.new(Roact)(Slider, { - defaultProps = defaultProps, -}) diff --git a/src/Slider.story.lua b/src/Slider.story.lua deleted file mode 100644 index 3807ade..0000000 --- a/src/Slider.story.lua +++ /dev/null @@ -1,82 +0,0 @@ -local Packages = script.Parent.Parent -local Roact = require(Packages.Roact) - -local Slider = require(script.Parent.Slider) -local Checkbox = require(script.Parent.Checkbox) - -local Constants = require(script.Parent.Constants) - -local MIN = 0 -local MAX = 10 -local STEP = 1 -local INIT = 3 - -local Wrapper = Roact.Component:extend("Wrapper") - -function Wrapper:init() - self:setState({ Disabled = false, Value = INIT }) -end - -function Wrapper.renderCustomBackground(props) - return Roact.createElement("Frame", { - BackgroundColor3 = Color3.fromHSV(210 / 360, props.Value / 10, if props.Disabled then 0.25 else 0.8), - Size = UDim2.fromScale(1, 1), - BorderSizePixel = 0, - }) -end - -function Wrapper:render() - return Roact.createElement("Frame", { - AnchorPoint = Vector2.new(0.5, 0.5), - Position = UDim2.fromScale(0.5, 0.5), - Size = UDim2.new(0, 100, 1, 0), - BackgroundTransparency = 1, - }, { - Layout = Roact.createElement("UIListLayout", { - FillDirection = Enum.FillDirection.Vertical, - VerticalAlignment = Enum.VerticalAlignment.Center, - SortOrder = Enum.SortOrder.LayoutOrder, - Padding = UDim.new(0, 8), - }), - Slider0 = Roact.createElement(Slider, { - Min = MIN, - Max = MAX, - Step = STEP, - Value = self.state.Value, - Disabled = self.state.Disabled, - OnChange = function(newValue) - self:setState({ Value = newValue }) - end, - LayoutOrder = 0, - }), - Slider1 = Roact.createElement(Slider, { - Min = MIN, - Max = MAX, - Step = STEP, - Background = self.renderCustomBackground, - Disabled = self.state.Disabled, - Value = self.state.Value, - OnChange = function(newValue) - self:setState({ Value = newValue }) - end, - LayoutOrder = 1, - }), - Disabled = Roact.createElement(Checkbox, { - Value = self.state.Disabled, - Label = "Disabled", - Alignment = Constants.CheckboxAlignment.Left, - OnActivated = function() - self:setState({ Disabled = not self.state.Disabled }) - end, - LayoutOrder = 2, - }), - }) -end - -return function(target) - local element = Roact.createElement(Wrapper) - local handle = Roact.mount(element, target) - return function() - Roact.unmount(handle) - end -end diff --git a/src/Splitter.lua b/src/Splitter.lua deleted file mode 100644 index 0f54714..0000000 --- a/src/Splitter.lua +++ /dev/null @@ -1,149 +0,0 @@ -local Packages = script.Parent.Parent - -local Roact = require(Packages.Roact) -local Hooks = require(Packages.RoactHooks) - -local Constants = require(script.Parent.Constants) -local useTheme = require(script.Parent.useTheme) -local usePlugin = require(script.Parent.usePlugin) -local useDragInput = require(script.Parent.useDragInput) - -local HANDLE_THICKNESS = 4 - -local defaultProps = { - Size = UDim2.fromScale(1, 1), - MinAlpha = 0.05, - MaxAlpha = 0.95, - Orientation = Constants.SplitterOrientation.Vertical, -} - --- NB: use purecomponent children to avoid re-rendering them every time split changes - -local function safeClamp(value, min, max) - return math.min(math.max(value, min), max) -end - -local function maybeFlip(shouldFlip, value) - if not shouldFlip then - return value - elseif typeof(value) == "Vector2" then - return Vector2.new(value.Y, value.X) - elseif typeof(value) == "UDim2" then - return UDim2.new(value.Height, value.Width) - end -end - -local function Splitter(props, hooks) - local theme = useTheme(hooks) - local plugin = usePlugin(hooks) - - local containerRef = hooks.useValue(Roact.createRef()) - - local drag = useDragInput(hooks, function(_, position) - local container = containerRef.value:getValue() - local size = container.AbsoluteSize - local offset = Vector2.new(position.x, position.y) - container.AbsolutePosition - offset = Vector2.new( - safeClamp(offset.x, HANDLE_THICKNESS + 1, size.x - HANDLE_THICKNESS - 1), - safeClamp(offset.y, HANDLE_THICKNESS + 1, size.y - HANDLE_THICKNESS - 1) - ) - local relative = offset / size - local alpha = Vector2.new( - safeClamp(relative.x, props.MinAlpha, props.MaxAlpha), - safeClamp(relative.y, props.MinAlpha, props.MaxAlpha) - ) - local newAlpha = if props.Orientation == Constants.SplitterOrientation.Vertical then alpha.x else alpha.y - if newAlpha ~= props.Alpha then - props.OnAlphaChanged(newAlpha) - end - end) - - local mouseIconId = hooks.useValue(nil) - local mouseIconUsed = hooks.useValue(nil) - - local function resetMouseIcon() - if plugin and mouseIconId.value then - plugin.popMouseIcon(mouseIconId.value) - mouseIconId.value = nil - mouseIconUsed.value = nil - end - end - - hooks.useEffect(function() - if plugin ~= nil then - local using = drag.hovered or drag.active - local icon = string.format( - "rbxasset://SystemCursors/Split%s", - if props.Orientation == Constants.SplitterOrientation.Vertical then "EW" else "NS" - ) - if using then - if not mouseIconId.value then - mouseIconId.value = plugin.pushMouseIcon(icon) - elseif mouseIconUsed.value ~= icon then - plugin.popMouseIcon(mouseIconId.value) - mouseIconId.value = plugin.pushMouseIcon(icon) - end - mouseIconUsed.value = icon - elseif not using and mouseIconId.value then - resetMouseIcon() - end - end - end, { plugin, drag.hovered, drag.active, props.Orientation }) - - hooks.useEffect(function() - if props.Disabled == true then - drag.cancel() - end - end, { props.Disabled }) - - hooks.useEffect(function() - return resetMouseIcon - end, {}) - - local alpha = safeClamp(props.Alpha, props.MinAlpha, props.MaxAlpha) - local barColor = theme:GetColor(Enum.StudioStyleGuideColor.DialogButton) - local flip = props.Orientation ~= Constants.SplitterOrientation.Vertical - - return Roact.createElement("Frame", { - Size = props.Size, - Position = props.Position, - AnchorPoint = props.AnchorPoint, - ZIndex = props.ZIndex, - LayoutOrder = props.LayoutOrder, - BackgroundTransparency = 1, - [Roact.Ref] = containerRef.value, - }, { - Side0 = Roact.createElement("Frame", { - Size = maybeFlip(flip, UDim2.new(alpha, -HANDLE_THICKNESS / 2, 1, 0)), - BackgroundTransparency = 1, - ZIndex = 0, - ClipsDescendants = true, - }, { Content = props[Roact.Children][1] }), - Side1 = Roact.createElement("Frame", { - AnchorPoint = maybeFlip(flip, Vector2.new(1, 0)), - Position = maybeFlip(flip, UDim2.fromScale(1, 0)), - Size = maybeFlip(flip, UDim2.new(1 - alpha, -HANDLE_THICKNESS / 2, 1, 0)), - BackgroundTransparency = 1, - ZIndex = 0, - ClipsDescendants = true, - }, { Content = props[Roact.Children][2] }), - Bar = Roact.createElement("TextButton", { - Active = false, - AutoButtonColor = false, - Text = "", - AnchorPoint = maybeFlip(flip, Vector2.new(0.5, 0)), - Position = maybeFlip(flip, UDim2.fromScale(alpha, 0)), - Size = maybeFlip(flip, UDim2.new(0, HANDLE_THICKNESS, 1, 0)), - BackgroundColor3 = barColor, - BorderColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Border), - BackgroundTransparency = props.Disabled and 0.75 or 0, - ZIndex = 1, - [Roact.Event.InputBegan] = if not props.Disabled then drag.onInputBegan else nil, - [Roact.Event.InputEnded] = if not props.Disabled then drag.onInputEnded else nil, - }), - }) -end - -return Hooks.new(Roact)(Splitter, { - defaultProps = defaultProps, -}) diff --git a/src/Splitter.story.lua b/src/Splitter.story.lua deleted file mode 100644 index 77eb87a..0000000 --- a/src/Splitter.story.lua +++ /dev/null @@ -1,93 +0,0 @@ -local Packages = script.Parent.Parent -local Roact = require(Packages.Roact) - -local Constants = require(script.Parent.Constants) - -local Splitter = require(script.Parent.Splitter) -local PluginProvider = require(script.Parent.PluginProvider) - -local Label = require(script.Parent.Label) -local ScrollFrame = require(script.Parent.ScrollFrame) -local Dropdown = require(script.Parent.Dropdown) - -local Wrapper = Roact.Component:extend("Wrapper") - -function Wrapper:init() - self:setState({ - Alpha0 = 0.5, - Alpha1 = 0.5, - Alpha2 = 0.5, - }) -end - -function Wrapper:render() - local scrollContents = {} - for i = 1, 20 do - scrollContents[i] = Roact.createElement(Label, { - Size = UDim2.new(1, 0, 0, 20), - Text = "Item " .. i, - LayoutOrder = i, - }) - end - - return Roact.createElement(Splitter, { - Alpha = self.state.Alpha0, - OnAlphaChanged = function(newAlpha) - self:setState({ Alpha0 = newAlpha }) - end, - Orientation = Constants.SplitterOrientation.Vertical, - }, { - [1] = Roact.createElement(ScrollFrame, { - Size = UDim2.fromScale(1, 1), - }, scrollContents), - [2] = Roact.createElement(Splitter, { - Alpha = self.state.Alpha1, - OnAlphaChanged = function(newAlpha) - self:setState({ Alpha1 = newAlpha }) - end, - Orientation = Constants.SplitterOrientation.Horizontal, - }, { - [1] = Roact.createElement(Splitter, { - Alpha = self.state.Alpha2, - OnAlphaChanged = function(newAlpha) - self:setState({ Alpha2 = newAlpha }) - end, - Orientation = Constants.SplitterOrientation.Vertical, - Disabled = true, - }, { - [1] = Roact.createElement(Label, { - Size = UDim2.fromScale(1, 1), - Text = "Side 2(1)(1)", - }), - [2] = Roact.createElement(Label, { - Size = UDim2.fromScale(1, 1), - Text = "Side 2(1)(2)", - }), - }), - [2] = Roact.createElement(Dropdown, { - AnchorPoint = Vector2.new(0.5, 0.5), - Position = UDim2.fromScale(0.5, 0.5), - Width = UDim.new(0.4, 50), - SelectedItem = "Test", - Items = { "Test", "Example", "Placeholder", "Dummy", "Sample" }, - OnItemSelected = function() end, - }), - }), - }) -end - -return function(target) - -- hoarcekat does not provide a way to access its plugin instance - -- this is a little hacky but acceptable since it's purely for the story - -- selene: allow(undefined_variable) - local plugin = PluginManager():CreatePlugin() - local element = Roact.createElement(PluginProvider, { - Plugin = plugin, - }, { - Main = Roact.createElement(Wrapper), - }) - local handle = Roact.mount(element, target) - return function() - Roact.unmount(handle) - end -end diff --git a/src/Stories/Background.story.luau b/src/Stories/Background.story.luau new file mode 100644 index 0000000..64a44fd --- /dev/null +++ b/src/Stories/Background.story.luau @@ -0,0 +1,10 @@ +local React = require("@pkg/@jsdotlua/react") + +local Background = require("../Components/Background") +local createStory = require("./Helpers/createStory") + +local function Story() + return React.createElement(Background) +end + +return createStory(Story) diff --git a/src/Stories/Button.story.luau b/src/Stories/Button.story.luau new file mode 100644 index 0000000..f851b6b --- /dev/null +++ b/src/Stories/Button.story.luau @@ -0,0 +1,90 @@ +local React = require("@pkg/@jsdotlua/react") + +local Button = require("../Components/Button") +local createStory = require("./Helpers/createStory") + +local function StoryButton(props: { + Text: string?, + HasIcon: boolean?, + Disabled: boolean?, +}) + return React.createElement(Button, { + LayoutOrder = if props.Disabled then 2 else 1, + Icon = if props.HasIcon + then { + Image = "rbxasset://studio_svg_textures/Shared/InsertableObjects/Dark/Standard/Part.png", + Size = Vector2.one * 16, + UseThemeColor = true, + Alignment = Enum.HorizontalAlignment.Left, + } + else nil, + Text = props.Text, + OnActivated = if not props.Disabled then function() end else nil, + Disabled = props.Disabled, + AutomaticSize = Enum.AutomaticSize.XY, + }) +end + +local function StoryItem(props: { + LayoutOrder: number, + Text: string?, + HasIcon: boolean?, + Disabled: boolean?, +}) + local height, setHeight = React.useBinding(0) + + return React.createElement("Frame", { + Size = height:map(function(value) + return UDim2.new(1, 0, 0, value) + end), + LayoutOrder = props.LayoutOrder, + BackgroundTransparency = 1, + }, { + Layout = React.createElement("UIListLayout", { + Padding = UDim.new(0, 10), + SortOrder = Enum.SortOrder.LayoutOrder, + FillDirection = Enum.FillDirection.Horizontal, + HorizontalAlignment = Enum.HorizontalAlignment.Center, + [React.Change.AbsoluteContentSize] = function(rbx) + setHeight(rbx.AbsoluteContentSize.Y) + end, + }), + Enabled = React.createElement(StoryButton, { + Text = props.Text, + HasIcon = props.HasIcon, + }), + Disabled = React.createElement(StoryButton, { + Text = props.Text, + HasIcon = props.HasIcon, + Disabled = true, + }), + }) +end + +local function Story() + return React.createElement(React.Fragment, {}, { + Icon = React.createElement(StoryItem, { + LayoutOrder = 1, + HasIcon = true, + }), + Text = React.createElement(StoryItem, { + LayoutOrder = 2, + Text = "Example Text", + }), + TextLonger = React.createElement(StoryItem, { + LayoutOrder = 3, + Text = "Example Longer Text", + }), + TextMulti = React.createElement(StoryItem, { + LayoutOrder = 4, + Text = "Example Text\nover two lines", + }), + IconTextIcon = React.createElement(StoryItem, { + LayoutOrder = 5, + HasIcon = true, + Text = "Example Text with Icon", + }), + }) +end + +return createStory(Story) diff --git a/src/Stories/Checkbox.story.luau b/src/Stories/Checkbox.story.luau new file mode 100644 index 0000000..cc229ca --- /dev/null +++ b/src/Stories/Checkbox.story.luau @@ -0,0 +1,71 @@ +local React = require("@pkg/@jsdotlua/react") + +local Checkbox = require("../Components/Checkbox") +local createStory = require("./Helpers/createStory") + +local function StoryItem(props: { + LayoutOrder: number, + Value: boolean?, + Label: string, +}) + return React.createElement("Frame", { + Size = UDim2.new(0, 200, 0, 50), + BackgroundTransparency = 1, + LayoutOrder = props.LayoutOrder, + }, { + Layout = React.createElement("UIListLayout", { + SortOrder = Enum.SortOrder.LayoutOrder, + Padding = UDim.new(0, 2), + VerticalAlignment = Enum.VerticalAlignment.Center, + }), + Enabled = React.createElement(Checkbox, { + Label = props.Label, + Value = props.Value, + OnChanged = function() end, + LayoutOrder = 1, + }), + Disabled = React.createElement(Checkbox, { + Label = `{props.Label} (Disabled)`, + Value = props.Value, + OnChanged = function() end, + Disabled = true, + LayoutOrder = 2, + }), + }) +end + +local function Story() + local value, setValue = React.useState(true) + + return React.createElement(React.Fragment, {}, { + Interactive = React.createElement(Checkbox, { + Size = UDim2.fromOffset(200, 20), + Label = "Interactive (try me)", + Value = value, + OnChanged = function() + setValue(not value) + end, + LayoutOrder = 1, + }), + + True = React.createElement(StoryItem, { + Label = "True", + Value = true, + LayoutOrder = 2, + }), + + False = React.createElement(StoryItem, { + Label = "False", + Value = false, + LayoutOrder = 3, + }), + + Indeterminate = React.createElement(StoryItem, { + Label = "Indeterminate", + Value = nil, + LayoutOrder = 4, + }), + }) +end + +return createStory(Story) diff --git a/src/Stories/ColorPicker.story.luau b/src/Stories/ColorPicker.story.luau new file mode 100644 index 0000000..d5fc5d3 --- /dev/null +++ b/src/Stories/ColorPicker.story.luau @@ -0,0 +1,23 @@ +local React = require("@pkg/@jsdotlua/react") + +local ColorPicker = require("../Components/ColorPicker") +local createStory = require("./Helpers/createStory") + +local function StoryItem(props: { Disabled: boolean? }) + local color, setColor = React.useState(Color3.fromRGB(255, 255, 0)) + + return React.createElement(ColorPicker, { + Color = color, + OnChanged = setColor, + Disabled = props.Disabled, + }) +end + +local function Story() + return React.createElement(React.Fragment, {}, { + Enabled = React.createElement(StoryItem), + Disabled = React.createElement(StoryItem, { Disabled = true }), + }) +end + +return createStory(Story) diff --git a/src/Stories/DropShadowFrame.story.luau b/src/Stories/DropShadowFrame.story.luau new file mode 100644 index 0000000..679a736 --- /dev/null +++ b/src/Stories/DropShadowFrame.story.luau @@ -0,0 +1,42 @@ +local React = require("@pkg/@jsdotlua/react") + +local Checkbox = require("../Components/Checkbox") +local DropShadowFrame = require("../Components/DropShadowFrame") +local Label = require("../Components/Label") + +local createStory = require("./Helpers/createStory") + +local function Story() + local boxValue, setBoxValue = React.useState(false) + + return React.createElement(DropShadowFrame, { + Size = UDim2.fromOffset(175, 75), + }, { + Layout = React.createElement("UIListLayout", { + SortOrder = Enum.SortOrder.LayoutOrder, + Padding = UDim.new(0, 10), + }), + Padding = React.createElement("UIPadding", { + PaddingLeft = UDim.new(0, 10), + PaddingRight = UDim.new(0, 10), + PaddingTop = UDim.new(0, 10), + PaddingBottom = UDim.new(0, 10), + }), + Label = React.createElement(Label, { + LayoutOrder = 1, + Text = "Example label", + Size = UDim2.new(1, 0, 0, 16), + TextXAlignment = Enum.TextXAlignment.Left, + }), + Checkbox = React.createElement(Checkbox, { + LayoutOrder = 2, + Value = boxValue, + OnChanged = function() + setBoxValue(not boxValue) + end, + Label = "Example checkbox", + }), + }) +end + +return createStory(Story) diff --git a/src/Stories/Dropdown.story.luau b/src/Stories/Dropdown.story.luau new file mode 100644 index 0000000..b9dc837 --- /dev/null +++ b/src/Stories/Dropdown.story.luau @@ -0,0 +1,81 @@ +local React = require("@pkg/@jsdotlua/react") + +local Constants = require("../Constants") +local Dropdown = require("../Components/Dropdown") + +local createStory = require("./Helpers/createStory") +local useTheme = require("../Hooks/useTheme") + +local classNames = { + "Part", + "Script", + "Player", + "Folder", + "Tool", + "SpawnLocation", + "MeshPart", + "Model", + "ClickDetector", + "Decal", + "ProximityPrompt", + "SurfaceAppearance", + "Texture", + "Animation", + "Accessory", + "Humanoid", +} + +-- hack to get themed class images +local function getClassImage(className: string, theme: StudioTheme) + return `rbxasset://studio_svg_textures/Shared/InsertableObjects/{theme}/Standard/{className}.png` +end + +local function StoryItem(props: { + LayoutOrder: number, + Disabled: boolean?, +}) + local theme = useTheme() + + local selectedClassName: string?, setSelectedClassName = React.useState(nil :: string?) + local classes = {} + for i, className in classNames do + classes[i] = { + Id = className, + Text = className, + Icon = { + Image = getClassImage(className, theme), + Size = Vector2.one * 16, + }, + } + end + + return React.createElement(Dropdown, { + Size = UDim2.fromOffset(200, Constants.DefaultDropdownHeight), + BackgroundTransparency = 1, + LayoutOrder = props.LayoutOrder, + Items = classes, + SelectedItem = selectedClassName, + OnItemSelected = function(newName: string?) + setSelectedClassName(newName) + end, + DefaultText = "Select a Class...", + MaxVisibleRows = 8, + RowHeight = 24, + ClearButton = true, + Disabled = props.Disabled, + }) +end + +local function Story() + return React.createElement(React.Fragment, {}, { + Enabled = React.createElement(StoryItem, { + LayoutOrder = 1, + }), + Disabled = React.createElement(StoryItem, { + LayoutOrder = 2, + Disabled = true, + }), + }) +end + +return createStory(Story) diff --git a/src/Stories/Helpers/createStory.luau b/src/Stories/Helpers/createStory.luau new file mode 100644 index 0000000..7229787 --- /dev/null +++ b/src/Stories/Helpers/createStory.luau @@ -0,0 +1,101 @@ +local React = require("@pkg/@jsdotlua/react") +local ReactRoblox = require("@pkg/@jsdotlua/react-roblox") + +local PluginProvider = require("../../Components/PluginProvider") +local ScrollFrame = require("../../Components/ScrollFrame") +local ThemeContext = require("../../Contexts/ThemeContext") +local getStoryPlugin = require("./getStoryPlugin") + +local themes = settings().Studio:GetAvailableThemes() +themes[1], themes[2] = themes[2], themes[1] + +local function StoryTheme(props: { + Theme: StudioTheme, + Size: UDim2, + LayoutOrder: number, +} & { + children: React.ReactNode, +}) + return React.createElement("Frame", { + Size = props.Size, + LayoutOrder = props.LayoutOrder, + BackgroundColor3 = props.Theme:GetColor(Enum.StudioStyleGuideColor.MainBackground), + BorderSizePixel = 0, + }, { + Provider = React.createElement(ThemeContext.Provider, { + value = props.Theme, + }, { + Inner = React.createElement(ScrollFrame, { + Layout = { + ClassName = "UIListLayout", + SortOrder = Enum.SortOrder.LayoutOrder, + HorizontalAlignment = Enum.HorizontalAlignment.Center, + VerticalAlignment = Enum.VerticalAlignment.Center, + Padding = UDim.new(0, 10), + }, + PaddingLeft = UDim.new(0, 10), + PaddingRight = UDim.new(0, 10), + PaddingTop = UDim.new(0, 10), + PaddingBottom = UDim.new(0, 10), + }, props.children), + }), + }) +end + +local function createStory(component: React.FC) + return function(target: Frame) + local items: { React.ReactNode } = {} + local order = 0 + local function getOrder() + order += 1 + return order + end + for i, theme in themes do + local widthOffset = if #themes > 2 and i == #themes then -1 else 0 + if i == 1 and #themes > 1 then + widthOffset -= 1 + end + table.insert( + items, + React.createElement(StoryTheme, { + Theme = theme, + Size = UDim2.new(1 / #themes, widthOffset, 1, 0), + LayoutOrder = getOrder(), + }, React.createElement(component)) + ) + -- invisible divider to prevent scrollframe edges overlapping as + -- they have default borders (outside, not inset) + if i < #themes then + table.insert( + items, + React.createElement("Frame", { + Size = UDim2.new(0, 2, 1, 0), + BackgroundTransparency = 1, + LayoutOrder = getOrder(), + }) + ) + end + end + + local element = React.createElement(PluginProvider, { + Plugin = getStoryPlugin(), + }, { + Layout = React.createElement("UIListLayout", { + SortOrder = Enum.SortOrder.LayoutOrder, + FillDirection = Enum.FillDirection.Horizontal, + }), + Padding = React.createElement("UIPadding", { + PaddingBottom = UDim.new(0, 45), -- tray buttons + }), + }, items) + + local root = ReactRoblox.createRoot(Instance.new("Folder")) + local portal = ReactRoblox.createPortal(element, target) + root:render(portal) + return function() + root:unmount() + end + end +end + +return createStory diff --git a/src/Stories/Helpers/getStoryPlugin.luau b/src/Stories/Helpers/getStoryPlugin.luau new file mode 100644 index 0000000..f7adb03 --- /dev/null +++ b/src/Stories/Helpers/getStoryPlugin.luau @@ -0,0 +1,9 @@ +--!nocheck +--!nolint UnknownGlobal + +-- selene: allow(undefined_variable) +local plugin = PluginManager():CreatePlugin() + +return function() + return plugin +end diff --git a/src/Stories/Label.story.luau b/src/Stories/Label.story.luau new file mode 100644 index 0000000..327b5fa --- /dev/null +++ b/src/Stories/Label.story.luau @@ -0,0 +1,56 @@ +local React = require("@pkg/@jsdotlua/react") + +local Label = require("../Components/Label") +local createStory = require("./Helpers/createStory") + +local styles = { + Enum.StudioStyleGuideColor.MainText, + Enum.StudioStyleGuideColor.SubText, + Enum.StudioStyleGuideColor.TitlebarText, + Enum.StudioStyleGuideColor.BrightText, + Enum.StudioStyleGuideColor.DimmedText, + Enum.StudioStyleGuideColor.ButtonText, + Enum.StudioStyleGuideColor.LinkText, + Enum.StudioStyleGuideColor.WarningText, + Enum.StudioStyleGuideColor.ErrorText, + Enum.StudioStyleGuideColor.InfoText, +} + +local function StoryItem(props: { + TextColorStyle: Enum.StudioStyleGuideColor, + LayoutOrder: number, +}) + return React.createElement("Frame", { + Size = UDim2.new(0, 170, 0, 40), + LayoutOrder = props.LayoutOrder, + BackgroundTransparency = 1, + }, { + Enabled = React.createElement(Label, { + Text = props.TextColorStyle.Name, + TextColorStyle = props.TextColorStyle, + TextXAlignment = Enum.TextXAlignment.Center, + Size = UDim2.new(1, 0, 0, 20), + }), + Disabled = React.createElement(Label, { + Text = `{props.TextColorStyle.Name} (Disabled)`, + Size = UDim2.new(1, 0, 0, 20), + Position = UDim2.fromOffset(0, 20), + TextColorStyle = props.TextColorStyle, + TextXAlignment = Enum.TextXAlignment.Center, + Disabled = true, + }), + }) +end + +local function Story() + local items = {} + for i, style in styles do + items[i] = React.createElement(StoryItem, { + TextColorStyle = style, + LayoutOrder = i, + }) + end + return React.createElement(React.Fragment, {}, items) +end + +return createStory(Story) diff --git a/src/Stories/LoadingDots.story.luau b/src/Stories/LoadingDots.story.luau new file mode 100644 index 0000000..1c64aa4 --- /dev/null +++ b/src/Stories/LoadingDots.story.luau @@ -0,0 +1,10 @@ +local React = require("@pkg/@jsdotlua/react") + +local LoadingDots = require("../Components/LoadingDots") +local createStory = require("./Helpers/createStory") + +local function Story() + return React.createElement(LoadingDots, {}) +end + +return createStory(Story) diff --git a/src/Stories/MainButton.story.luau b/src/Stories/MainButton.story.luau new file mode 100644 index 0000000..15f54a1 --- /dev/null +++ b/src/Stories/MainButton.story.luau @@ -0,0 +1,90 @@ +local React = require("@pkg/@jsdotlua/react") + +local MainButton = require("../Components/MainButton") +local createStory = require("./Helpers/createStory") + +local function StoryButton(props: { + Text: string?, + HasIcon: boolean?, + Disabled: boolean?, +}) + return React.createElement(MainButton, { + LayoutOrder = if props.Disabled then 2 else 1, + Icon = if props.HasIcon + then { + Image = "rbxasset://studio_svg_textures/Shared/InsertableObjects/Dark/Standard/Part.png", + Size = Vector2.one * 16, + UseThemeColor = true, + Alignment = Enum.HorizontalAlignment.Left, + } + else nil, + Text = props.Text, + OnActivated = if not props.Disabled then function() end else nil, + Disabled = props.Disabled, + AutomaticSize = Enum.AutomaticSize.XY, + }) +end + +local function StoryItem(props: { + LayoutOrder: number, + Text: string?, + HasIcon: boolean?, + Disabled: boolean?, +}) + local height, setHeight = React.useBinding(0) + + return React.createElement("Frame", { + Size = height:map(function(value) + return UDim2.new(1, 0, 0, value) + end), + LayoutOrder = props.LayoutOrder, + BackgroundTransparency = 1, + }, { + Layout = React.createElement("UIListLayout", { + Padding = UDim.new(0, 10), + SortOrder = Enum.SortOrder.LayoutOrder, + FillDirection = Enum.FillDirection.Horizontal, + HorizontalAlignment = Enum.HorizontalAlignment.Center, + [React.Change.AbsoluteContentSize] = function(rbx) + setHeight(rbx.AbsoluteContentSize.Y) + end, + }), + Enabled = React.createElement(StoryButton, { + Text = props.Text, + HasIcon = props.HasIcon, + }), + -- Disabled = React.createElement(StoryButton, { + -- Text = props.Text, + -- HasIcon = props.HasIcon, + -- Disabled = true, + -- }), + }) +end + +local function Story() + return React.createElement(React.Fragment, {}, { + Icon = React.createElement(StoryItem, { + LayoutOrder = 1, + HasIcon = true, + }), + Text = React.createElement(StoryItem, { + LayoutOrder = 2, + Text = "Example Text", + }), + TextLonger = React.createElement(StoryItem, { + LayoutOrder = 3, + Text = "Example Longer Text", + }), + TextMulti = React.createElement(StoryItem, { + LayoutOrder = 4, + Text = "Example Text\nover two lines", + }), + IconTextIcon = React.createElement(StoryItem, { + LayoutOrder = 5, + HasIcon = true, + Text = "Example Text with Icon", + }), + }) +end + +return createStory(Story) diff --git a/src/Stories/NumberSequencePicker.story.luau b/src/Stories/NumberSequencePicker.story.luau new file mode 100644 index 0000000..05f36e8 --- /dev/null +++ b/src/Stories/NumberSequencePicker.story.luau @@ -0,0 +1,28 @@ +local React = require("@pkg/@jsdotlua/react") + +local NumberSequencePicker = require("../Components/NumberSequencePicker") +local createStory = require("./Helpers/createStory") + +local function Story() + local value, setValue = React.useState(NumberSequence.new({ + NumberSequenceKeypoint.new(0.0, 0.00), + NumberSequenceKeypoint.new(0.4, 0.75, 0.10), + NumberSequenceKeypoint.new(0.5, 0.45, 0.15), + NumberSequenceKeypoint.new(0.8, 0.75), + NumberSequenceKeypoint.new(1.0, 0.50), + })) + + return React.createElement("Frame", { + BackgroundTransparency = 1, + Size = UDim2.fromScale(1, 1), + }, { + Picker = React.createElement(NumberSequencePicker, { + Value = value, + OnChanged = setValue, + AnchorPoint = Vector2.new(0.5, 0.5), + Position = UDim2.fromScale(0.5, 0.5), + }), + }) +end + +return createStory(Story) diff --git a/src/Stories/NumericInput.story.luau b/src/Stories/NumericInput.story.luau new file mode 100644 index 0000000..563c02e --- /dev/null +++ b/src/Stories/NumericInput.story.luau @@ -0,0 +1,79 @@ +local React = require("@pkg/@jsdotlua/react") + +local Constants = require("../Constants") +local NumericInput = require("../Components/NumericInput") +local createStory = require("./Helpers/createStory") + +local function StoryItem(props: { + LayoutOrder: number, + Arrows: boolean?, + Slider: boolean?, +}) + local value, setValue = React.useState(5) + + local min = 0 + local max = 10 + local step = 0.25 + + local function format(n: number) + return string.format("%.2f", n) + end + + return React.createElement("Frame", { + LayoutOrder = props.LayoutOrder, + Size = UDim2.new(0, 150, 0, Constants.DefaultInputHeight * 2 + 10), + BackgroundTransparency = 1, + }, { + Enabled = React.createElement(NumericInput, { + LayoutOrder = 1, + Size = UDim2.new(1, 0, 0, Constants.DefaultInputHeight), + Value = value, + Min = min, + Max = max, + Step = step, + ClearTextOnFocus = false, + OnValidChanged = setValue, + FormatValue = format, + Arrows = props.Arrows, + Slider = props.Slider, + }), + Disabled = React.createElement(NumericInput, { + LayoutOrder = 3, + Size = UDim2.new(1, 0, 0, Constants.DefaultInputHeight), + Position = UDim2.fromOffset(0, Constants.DefaultInputHeight + 5), + Value = value, + Min = min, + Max = max, + Step = step, + ClearTextOnFocus = false, + OnValidChanged = function() end, + FormatValue = format, + Arrows = props.Arrows, + Slider = props.Slider, + Disabled = true, + }), + }) +end + +local function Story() + return React.createElement(React.Fragment, {}, { + Regular = React.createElement(StoryItem, { + LayoutOrder = 1, + }), + Arrows = React.createElement(StoryItem, { + LayoutOrder = 2, + Arrows = true, + }), + Slider = React.createElement(StoryItem, { + LayoutOrder = 3, + Slider = true, + }), + Both = React.createElement(StoryItem, { + LayoutOrder = 4, + Arrows = true, + Slider = true, + }), + }) +end + +return createStory(Story) diff --git a/src/Stories/ProgressBar.story.luau b/src/Stories/ProgressBar.story.luau new file mode 100644 index 0000000..6fe6e69 --- /dev/null +++ b/src/Stories/ProgressBar.story.luau @@ -0,0 +1,64 @@ +local React = require("@pkg/@jsdotlua/react") + +local ProgressBar = require("../Components/ProgressBar") +local createStory = require("./Helpers/createStory") + +local HEIGHT = 14 + +local function StoryItem(props: { + Value: number, + Max: number?, + Formatter: ((number, number) -> string)?, + LayoutOrder: number, +}) + return React.createElement("Frame", { + Size = UDim2.new(1, 0, 0, HEIGHT), + LayoutOrder = props.LayoutOrder, + BackgroundTransparency = 1, + }, { + Enabled = React.createElement(ProgressBar, { + Value = props.Value, + Max = props.Max, + Formatter = props.Formatter, + --Size = UDim2.new(0.5, -5, 1, 0), + Size = UDim2.new(0, 225, 1, 0), + Position = UDim2.fromOffset(20, 0), + }), + -- Disabled = React.createElement(ProgressBar, { + -- Value = props.Value, + -- Max = props.Max, + -- Formatter = props.Formatter, + -- AnchorPoint = Vector2.new(1, 0), + -- Position = UDim2.fromScale(1, 0), + -- Size = UDim2.new(0.5, -5, 1, 0), + -- Disabled = true, + -- }), + }) +end + +local function Story() + return React.createElement(React.Fragment, {}, { + Zero = React.createElement(StoryItem, { + Value = 0, + LayoutOrder = 1, + }), + Fifty = React.createElement(StoryItem, { + Value = 0.5, + LayoutOrder = 1, + }), + Hundred = React.createElement(StoryItem, { + Value = 1, + LayoutOrder = 2, + }), + Custom = React.createElement(StoryItem, { + Value = 5, + Max = 14, + LayoutOrder = 3, + Formatter = function(value, max) + return `loaded {value} / {max} assets` + end, + }), + }) +end + +return createStory(Story) diff --git a/src/Stories/RadioButton.story.luau b/src/Stories/RadioButton.story.luau new file mode 100644 index 0000000..48baaa7 --- /dev/null +++ b/src/Stories/RadioButton.story.luau @@ -0,0 +1,27 @@ +local React = require("@pkg/@jsdotlua/react") + +local RadioButton = require("../Components/RadioButton") +local createStory = require("./Helpers/createStory") + +local function Story() + local value, setValue = React.useState(true) + + return React.createElement(React.Fragment, {}, { + Enabled = React.createElement(RadioButton, { + Label = "Enabled", + Value = value, + OnChanged = function() + setValue(not value) + end, + LayoutOrder = 1, + }), + Disabled = React.createElement(RadioButton, { + Label = "Disabled", + Value = value, + Disabled = true, + LayoutOrder = 2, + }), + }) +end + +return createStory(Story) diff --git a/src/Stories/ScrollFrame.story.luau b/src/Stories/ScrollFrame.story.luau new file mode 100644 index 0000000..1ba2cb6 --- /dev/null +++ b/src/Stories/ScrollFrame.story.luau @@ -0,0 +1,76 @@ +local React = require("@pkg/@jsdotlua/react") + +local Constants = require("../Constants") +local ScrollFrame = require("../Components/ScrollFrame") +local createStory = require("./Helpers/createStory") + +local numRows = 10 +local numCols = 10 + +local size = Vector2.new(48, 32) + +local function StoryRow(props: { + Row: number, +}) + local children = {} + for i = 1, numCols do + children[i] = React.createElement("TextLabel", { + LayoutOrder = i, + Text = string.format("%i,%i", i - 1, props.Row - 1), + Font = Constants.DefaultFont, + TextSize = Constants.DefaultTextSize, + TextColor3 = Color3.fromRGB(0, 0, 0), + Size = UDim2.new(0, size.X, 1, 0), + BorderSizePixel = 0, + BackgroundTransparency = 0, + BackgroundColor3 = Color3.fromHSV((i + props.Row) % numCols / numCols, 0.6, 0.8), + }) + end + return React.createElement("Frame", { + LayoutOrder = props.Row, + Size = UDim2.fromOffset(numCols * size.X, size.Y), + BackgroundTransparency = 1, + }, { + Layout = React.createElement("UIListLayout", { + FillDirection = Enum.FillDirection.Horizontal, + SortOrder = Enum.SortOrder.LayoutOrder, + }), + }, children) +end + +local function StoryScroller(props: { + Size: UDim2, + LayoutOrder: number, + Disabled: boolean?, +}) + local rows = {} + for i = 1, numRows do + rows[i] = React.createElement(StoryRow, { Row = i }) + end + + return React.createElement(ScrollFrame, { + ScrollingDirection = Enum.ScrollingDirection.XY, + Size = props.Size, + Disabled = props.Disabled, + Layout = { + ClassName = "UIListLayout", + SortOrder = Enum.SortOrder.LayoutOrder, + }, + }, rows) +end + +local function Story() + return React.createElement(React.Fragment, {}, { + Enabled = React.createElement(StoryScroller, { + Size = UDim2.new(1, -10, 0, 220), + LayoutOrder = 1, + }), + Disabled = React.createElement(StoryScroller, { + Size = UDim2.new(1, -10, 0, 220), + LayoutOrder = 2, + Disabled = true, + }), + }) +end + +return createStory(Story) diff --git a/src/Stories/Slider.story.luau b/src/Stories/Slider.story.luau new file mode 100644 index 0000000..23f976e --- /dev/null +++ b/src/Stories/Slider.story.luau @@ -0,0 +1,33 @@ +local React = require("@pkg/@jsdotlua/react") + +local Slider = require("../Components/Slider") +local createStory = require("./Helpers/createStory") + +local function StoryItem(props: { + LayoutOrder: number, + Disabled: boolean?, +}) + local value, setValue = React.useState(3) + return React.createElement(Slider, { + Value = value, + Min = 0, + Max = 10, + Step = 0, + OnChanged = setValue, + Disabled = props.Disabled, + }) +end + +local function Story() + return React.createElement(React.Fragment, {}, { + Enabled = React.createElement(StoryItem, { + LayoutOrder = 1, + }), + Disabled = React.createElement(StoryItem, { + LayoutOrder = 2, + Disabled = true, + }), + }) +end + +return createStory(Story) diff --git a/src/Stories/Splitter.story.luau b/src/Stories/Splitter.story.luau new file mode 100644 index 0000000..be50fc8 --- /dev/null +++ b/src/Stories/Splitter.story.luau @@ -0,0 +1,70 @@ +local React = require("@pkg/@jsdotlua/react") + +local Label = require("../Components/Label") +local Splitter = require("../Components/Splitter") +local createStory = require("./Helpers/createStory") +local useTheme = require("../Hooks/useTheme") + +local function StoryItem(props: { + Size: UDim2, + LayoutOrder: number, + Disabled: boolean?, +}) + local theme = useTheme() + + local alpha0, setAlpha0 = React.useState(0.5) + local alpha1, setAlpha1 = React.useState(0.5) + + local postText = if props.Disabled then "\n(Disabled)" else "" + + return React.createElement("Frame", { + Size = props.Size, + LayoutOrder = props.LayoutOrder, + BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.MainBackground), + BorderColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Border), + BorderMode = Enum.BorderMode.Inset, + }, { + Splitter = React.createElement(Splitter, { + Alpha = alpha0, + OnChanged = setAlpha0, + FillDirection = Enum.FillDirection.Vertical, + Disabled = props.Disabled, + }, { + Side0 = React.createElement(Label, { + Text = "Top" .. postText, + Disabled = props.Disabled, + }), + Side1 = React.createElement(Splitter, { + Alpha = alpha1, + OnChanged = setAlpha1, + FillDirection = Enum.FillDirection.Horizontal, + Disabled = props.Disabled, + }, { + Side0 = React.createElement(Label, { + Text = "Bottom Left" .. postText, + Disabled = props.Disabled, + }), + Side1 = React.createElement(Label, { + Text = "Bottom Right" .. postText, + Disabled = props.Disabled, + }), + }), + }), + }) +end + +local function Story() + return React.createElement(React.Fragment, {}, { + Enabled = React.createElement(StoryItem, { + Size = UDim2.new(1, 0, 0.5, -5), + LayoutOrder = 1, + }), + Disabled = React.createElement(StoryItem, { + Size = UDim2.new(1, 0, 0.5, -5), + LayoutOrder = 2, + Disabled = true, + }), + }) +end + +return createStory(Story) diff --git a/src/Stories/TabContainer.story.luau b/src/Stories/TabContainer.story.luau new file mode 100644 index 0000000..eb86625 --- /dev/null +++ b/src/Stories/TabContainer.story.luau @@ -0,0 +1,63 @@ +local React = require("@pkg/@jsdotlua/react") + +local TabContainer = require("../Components/TabContainer") +local createStory = require("./Helpers/createStory") + +local function StoryItemContent(props: { + BackgroundColor3: Color3, +}) + return React.createElement("Frame", { + Position = UDim2.fromOffset(10, 10), + Size = UDim2.fromOffset(50, 50), + BackgroundColor3 = props.BackgroundColor3, + }) +end + +local function StoryItem(props: { + LayoutOrder: number, + Disabled: boolean?, +}) + local selected, setSelected = React.useState("First") + + return React.createElement(TabContainer, { + Size = UDim2.new(1, -50, 0.5, -50), + LayoutOrder = props.LayoutOrder, + SelectedTab = selected, + OnTabSelected = setSelected, + Disabled = props.Disabled, + }, { + First = { + LayoutOrder = 1, + Content = React.createElement(StoryItemContent, { + BackgroundColor3 = Color3.fromRGB(255, 0, 255), + }), + }, + Second = { + LayoutOrder = 2, + Content = React.createElement(StoryItemContent, { + BackgroundColor3 = Color3.fromRGB(255, 255, 0), + }), + }, + Third = { + LayoutOrder = 3, + Content = React.createElement(StoryItemContent, { + BackgroundColor3 = Color3.fromRGB(0, 255, 255), + }), + Disabled = true, + }, + }) +end + +local function Story() + return React.createElement(React.Fragment, {}, { + Enabled = React.createElement(StoryItem, { + LayoutOrder = 1, + }), + Disabled = React.createElement(StoryItem, { + LayoutOrder = 2, + Disabled = true, + }), + }) +end + +return createStory(Story) diff --git a/src/Stories/TextInput.story.luau b/src/Stories/TextInput.story.luau new file mode 100644 index 0000000..f10c3ea --- /dev/null +++ b/src/Stories/TextInput.story.luau @@ -0,0 +1,55 @@ +local React = require("@pkg/@jsdotlua/react") + +local TextInput = require("../Components/TextInput") +local createStory = require("./Helpers/createStory") + +local function StoryItem(props: { + Label: string, + LayoutOrder: number, + Disabled: boolean?, + Filter: ((s: string) -> string)?, +}) + local text, setText = React.useState(if props.Disabled then props.Label else "") + + return React.createElement("Frame", { + Size = UDim2.fromOffset(175, 20), + LayoutOrder = props.LayoutOrder, + BackgroundTransparency = 1, + }, { + Input = React.createElement(TextInput, { + Text = text, + PlaceholderText = props.Label, + Disabled = props.Disabled, + OnChanged = function(newText) + local filtered = newText + if props.Filter then + filtered = props.Filter(newText) + end + setText(filtered) + end, + }), + }) +end + +local function Story() + return React.createElement(React.Fragment, {}, { + Enabled = React.createElement(StoryItem, { + Label = "Any text allowed", + LayoutOrder = 1, + }), + Filtered = React.createElement(StoryItem, { + Label = "Numbers only", + LayoutOrder = 2, + Filter = function(text) + return (string.gsub(text, "%D", "")) + end, + }), + Disabled = React.createElement(StoryItem, { + Label = "Disabled", + LayoutOrder = 3, + Disabled = true, + }), + }) +end + +return createStory(Story) diff --git a/src/TabContainer.story.lua b/src/TabContainer.story.lua deleted file mode 100644 index 3753fec..0000000 --- a/src/TabContainer.story.lua +++ /dev/null @@ -1,80 +0,0 @@ -local Packages = script.Parent.Parent -local Roact = require(Packages.Roact) - -local TabContainer = require(script.Parent.TabContainer) - -local Label = require(script.Parent.Label) -local Button = require(script.Parent.Button) -local TextInput = require(script.Parent.TextInput) - -local function Centered(props) - return Roact.createFragment({ - Layout = Roact.createElement("UIListLayout", { - HorizontalAlignment = Enum.HorizontalAlignment.Center, - VerticalAlignment = Enum.VerticalAlignment.Center, - }), - Child = Roact.oneChild(props[Roact.Children]), - }) -end - -local Wrapper = Roact.Component:extend("Wrapper") - -function Wrapper:init() - self:setState({ - SelectedTab = "Label", - }) -end - -function Wrapper:render() - return Roact.createElement(TabContainer, { - AnchorPoint = Vector2.new(0.5, 0.5), - Position = UDim2.fromScale(0.5, 0.5), - Size = UDim2.new(1, -100, 1, -150), - Tabs = { - { - Name = "Label", - Content = Roact.createElement(Centered, {}, { - Label = Roact.createElement(Label, { - Text = "Label", - }), - }), - }, - { - Name = "Button", - Content = Roact.createElement(Centered, {}, { - Button = Roact.createElement(Button, { - Size = UDim2.fromOffset(100, 30), - Text = "Button", - OnActivated = function() end, - }), - }), - }, - { - Name = "TextInput", - Content = Roact.createElement(Centered, {}, { - TextInput = Roact.createElement(TextInput, { - Size = UDim2.fromOffset(100, 21), - OnChanged = function() end, - PlaceholderText = "Placeholder", - }), - }), - }, - { - Name = "Disabled", - Disabled = true, - }, - }, - SelectedTab = self.state.SelectedTab, - OnTabSelected = function(tab) - self:setState({ SelectedTab = tab }) - end, - }) -end - -return function(target) - local element = Roact.createElement(Wrapper) - local handle = Roact.mount(element, target) - return function() - Roact.unmount(handle) - end -end diff --git a/src/TabContainer/TabButton.lua b/src/TabContainer/TabButton.lua deleted file mode 100644 index 729136d..0000000 --- a/src/TabContainer/TabButton.lua +++ /dev/null @@ -1,74 +0,0 @@ -local Packages = script.Parent.Parent.Parent - -local Roact = require(Packages.Roact) -local Hooks = require(Packages.RoactHooks) - -local useTheme = require(script.Parent.Parent.useTheme) - -local function TabButton(props, hooks) - local theme = useTheme(hooks) - local hovered, setHovered = hooks.useState(false) - local pressed, setPressed = hooks.useState(false) - - local onInputBegan = function(_, input) - if props.Disabled then - return - elseif input.UserInputType == Enum.UserInputType.MouseButton1 then - setPressed(true) - elseif input.UserInputType == Enum.UserInputType.MouseMovement then - setHovered(true) - end - end - - local onInputEnded = function(_, input) - if input.UserInputType == Enum.UserInputType.MouseButton1 then - setPressed(false) - elseif input.UserInputType == Enum.UserInputType.MouseMovement then - setHovered(false) - end - end - - local color = Enum.StudioStyleGuideColor.Button - if props.Selected then - color = Enum.StudioStyleGuideColor.MainBackground - end - local modifier = Enum.StudioStyleGuideModifier.Default - if props.Disabled then - modifier = Enum.StudioStyleGuideModifier.Disabled - elseif props.Selected then - modifier = Enum.StudioStyleGuideModifier.Pressed - elseif pressed then - color = Enum.StudioStyleGuideColor.ButtonBorder - elseif hovered then - modifier = Enum.StudioStyleGuideModifier.Hover - end - - return Roact.createElement("TextButton", { - AutoButtonColor = false, - BackgroundColor3 = theme:GetColor(color, modifier), - BorderColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Border, modifier), - LayoutOrder = props.LayoutOrder, - Size = props.Size, - Text = props.Text, - Font = Enum.Font.SourceSans, - TextColor3 = theme:GetColor(Enum.StudioStyleGuideColor.MainText, modifier), - TextTruncate = Enum.TextTruncate.AtEnd, - TextSize = 14, - [Roact.Event.InputBegan] = onInputBegan, - [Roact.Event.InputEnded] = onInputEnded, - [Roact.Event.Activated] = function() - if not props.Disabled then - props.OnActivated() - end - end, - }, { - Indicator = props.Selected and Roact.createElement("Frame", { - BackgroundColor3 = Color3.fromRGB(0, 162, 255), - BackgroundTransparency = props.Disabled and 0.8 or 0, - BorderSizePixel = 0, - Size = UDim2.new(1, 0, 0, 2), - }), - }) -end - -return Hooks.new(Roact)(TabButton) diff --git a/src/TabContainer/init.lua b/src/TabContainer/init.lua deleted file mode 100644 index 5df3457..0000000 --- a/src/TabContainer/init.lua +++ /dev/null @@ -1,87 +0,0 @@ -local Packages = script.Parent.Parent - -local Roact = require(Packages.Roact) -local Hooks = require(Packages.RoactHooks) - -local useTheme = require(script.Parent.useTheme) -local TabButton = require(script.TabButton) - -local TAB_HEIGHT = 30 - -local function TabContainer(props, hooks) - local theme = useTheme(hooks) - - local tabs = {} - local selectedTabIndex - for i, tab in ipairs(props.Tabs) do - local isSelectedTab = props.SelectedTab == tab.Name - if isSelectedTab then - selectedTabIndex = i - end - tabs[i] = Roact.createElement(TabButton, { - Size = UDim2.fromScale(1 / #props.Tabs, 1), - LayoutOrder = i, - Text = tab.Name, - Selected = isSelectedTab, - Disabled = tab.Disabled, - OnActivated = function() - props.OnTabSelected(tab.Name) - end, - }) - end - - local page = nil - for _, tab in ipairs(props.Tabs) do - if tab.Name == props.SelectedTab then - page = tab.Content - break - end - end - - return Roact.createElement("Frame", { - Size = props.Size or UDim2.fromScale(1, 1), - Position = props.Position or UDim2.fromScale(0, 0), - AnchorPoint = props.AnchorPoint or Vector2.new(0, 0), - LayoutOrder = props.LayoutOrder or 0, - ZIndex = props.ZIndex or 1, - BorderColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Border), - BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.MainBackground), - }, { - Top = Roact.createElement("Frame", { - ZIndex = 2, - Size = UDim2.new(1, 0, 0, TAB_HEIGHT), - BackgroundTransparency = 1, - }, { - BridgeToSelected = selectedTabIndex and Roact.createElement("Frame", { - BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.MainBackground), - BorderSizePixel = 0, - -- We do not want to cover the leftmost border-size in-between the tabs. Hence, subtracting one pixel (which is the width of the border). - -- However, on the very end, we want to cover that border since there's no tabs to divide against. - Size = UDim2.new(1 / #props.Tabs, if selectedTabIndex == #props.Tabs then 0 else -1, 0, 1), - Position = UDim2.new(1 / #props.Tabs * (selectedTabIndex - 1), 0, 1, 0), - }), - TabsContainer = Roact.createElement("Frame", { - Size = UDim2.fromScale(1, 1), - BackgroundTransparency = 1, - }, { - Tabs = Roact.createFragment(tabs), - Layout = Roact.createElement("UIListLayout", { - SortOrder = Enum.SortOrder.LayoutOrder, - FillDirection = Enum.FillDirection.Horizontal, - }), - }), - }), - Content = Roact.createElement("Frame", { - ZIndex = 1, - AnchorPoint = Vector2.new(0, 1), - Position = UDim2.fromScale(0, 1), - Size = UDim2.new(1, 0, 1, -TAB_HEIGHT - 1), -- extra px for outer border - BackgroundTransparency = 1, - ClipsDescendants = true, - }, { - Page = page, - }), - }) -end - -return Hooks.new(Roact)(TabContainer) diff --git a/src/TextInput.lua b/src/TextInput.lua deleted file mode 100644 index 95f9d74..0000000 --- a/src/TextInput.lua +++ /dev/null @@ -1,176 +0,0 @@ -local TextService = game:GetService("TextService") - -local Packages = script.Parent.Parent -local Roact = require(Packages.Roact) -local Hooks = require(Packages.RoactHooks) - -local joinDictionaries = require(script.Parent.joinDictionaries) -local withTheme = require(script.Parent.withTheme) - -local Constants = require(script.Parent.Constants) - -local PLACEHOLDER_TEXT_COLOR = Color3.fromRGB(102, 102, 102) -- works for both themes -local EDGE_PADDING_PX = 5 - -local defaultProps = { - Size = UDim2.new(1, 0, 0, 21), - Disabled = false, - Text = "", - PlaceholderText = "", - OnFocused = function() end, - OnFocusLost = function() end, - OnChanged = function() end, -} - -local function getTextWidth(text) - local frameSize = Vector2.new(math.huge, math.huge) - if #text == 0 then - return 0 - end - return TextService:GetTextSize(text, Constants.TextSize, Constants.Font, frameSize).X + 1 -end - -local function TextInput(props, hooks) - local hovered, setHovered = hooks.useState(false) - local focused, setFocused = hooks.useState(false) - - local containerRef = hooks.useValue(Roact.createRef()) - local innerOffset = hooks.useValue(0) - local cursorPosition, setCursorPosition = hooks.useState(-1) - - local mainModifier = Enum.StudioStyleGuideModifier.Default - local borderModifier = Enum.StudioStyleGuideModifier.Default - if props.Disabled then - mainModifier = Enum.StudioStyleGuideModifier.Disabled - borderModifier = Enum.StudioStyleGuideModifier.Disabled - elseif focused then - borderModifier = Enum.StudioStyleGuideModifier.Selected - elseif hovered then - borderModifier = Enum.StudioStyleGuideModifier.Hover - end - - local function onInputBegan(_, inputObject) - if props.Disabled then - return - elseif inputObject.UserInputType == Enum.UserInputType.MouseMovement then - setHovered(true) - end - end - - local function onInputEnded(_, inputObject) - if props.Disabled then - return - elseif inputObject.UserInputType == Enum.UserInputType.MouseMovement then - setHovered(false) - end - end - - local function onFocused() - setFocused(true) - props.OnFocused() - end - - local function onFocusLost(rbx, enterPressed, inputObject) - setFocused(false) - props.OnFocusLost(rbx.Text, enterPressed, inputObject) - end - - local function onTextChanged(rbx) - props.OnChanged(rbx.Text) - end - - local function onCursorChanged(rbx) - setCursorPosition(rbx.CursorPosition) - end - - local container = containerRef.value:getValue() - if not props.Disabled and container then - local min = EDGE_PADDING_PX - local max = container.AbsoluteSize.X - EDGE_PADDING_PX - - local text = string.sub(props.Text, 1, cursorPosition - 1) - local offset = getTextWidth(text) + EDGE_PADDING_PX - - local innerArea = max - min - local fullOffset = offset + innerOffset.value - local fullTextWidth = getTextWidth(props.Text) - if fullTextWidth <= innerArea or not focused then - innerOffset.value = 0 - else - if fullOffset < min then - innerOffset.value += min - fullOffset - elseif fullOffset > max then - innerOffset.value -= fullOffset - max - end - innerOffset.value = math.max(innerOffset.value, innerArea - fullTextWidth) - end - else - innerOffset.value = 0 - end - - local textFieldSize = UDim2.fromScale(1, 1) - if container and focused then - local fullTextWidth = getTextWidth(props.Text) - textFieldSize = UDim2.new( - UDim.new(0, math.max(container.AbsoluteSize.X, fullTextWidth + EDGE_PADDING_PX * 2)), - UDim.new(1, 0) - ) - end - - local padding = Roact.createElement("UIPadding", { - PaddingLeft = UDim.new(0, EDGE_PADDING_PX), - PaddingRight = UDim.new(0, EDGE_PADDING_PX), - }) - - return withTheme(function(theme) - local textFieldProps = { - Size = textFieldSize, - Position = UDim2.fromOffset(innerOffset.value, 0), - BackgroundTransparency = 1, - Font = Constants.Font, - Text = props.Text, - TextSize = Constants.TextSize, - TextColor3 = theme:GetColor(Enum.StudioStyleGuideColor.MainText, mainModifier), - TextXAlignment = Enum.TextXAlignment.Left, - TextTruncate = if focused then Enum.TextTruncate.None else Enum.TextTruncate.AtEnd, - } - - local textField = props.Disabled and Roact.createElement("TextLabel", textFieldProps, { Padding = padding }) - or Roact.createElement( - "TextBox", - joinDictionaries(textFieldProps, { - PlaceholderText = props.PlaceholderText, - PlaceholderColor3 = PLACEHOLDER_TEXT_COLOR, - ClearTextOnFocus = props.ClearTextOnFocus, - MultiLine = false, - [Roact.Event.Focused] = onFocused, - [Roact.Event.FocusLost] = onFocusLost, - [Roact.Event.InputBegan] = onInputBegan, - [Roact.Event.InputEnded] = onInputEnded, - [Roact.Change.Text] = onTextChanged, - [Roact.Change.CursorPosition] = onCursorChanged, - }), - { Padding = padding } - ) - - return Roact.createElement("Frame", { - AnchorPoint = props.AnchorPoint, - Position = props.Position, - Size = props.Size, - LayoutOrder = props.LayoutOrder, - ZIndex = props.ZIndex, - BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.InputFieldBackground, mainModifier), - BorderColor3 = theme:GetColor(Enum.StudioStyleGuideColor.InputFieldBorder, borderModifier), - BorderMode = Enum.BorderMode.Inset, - ClipsDescendants = true, - [Roact.Ref] = containerRef.value, - }, { - TextField = textField, - Children = Roact.createFragment(props[Roact.Children]), - }) - end) -end - -return Hooks.new(Roact)(TextInput, { - defaultProps = defaultProps, -}) diff --git a/src/TextInput.story.lua b/src/TextInput.story.lua deleted file mode 100644 index dc76839..0000000 --- a/src/TextInput.story.lua +++ /dev/null @@ -1,48 +0,0 @@ -local Packages = script.Parent.Parent - -local Roact = require(Packages.Roact) -local Hooks = require(Packages.RoactHooks) - -local TextInput = require(script.Parent.TextInput) - -local Helper = Hooks.new(Roact)(function(props, hooks) - local text, setText = hooks.useState(props.InitText) - return Roact.createElement(TextInput, { - LayoutOrder = props.LayoutOrder, - PlaceholderText = props.PlaceholderText, - Disabled = props.Disabled, - Text = text, - OnChanged = setText, - }) -end) - -return function(target) - local element = Roact.createElement("Frame", { - AnchorPoint = Vector2.new(0.5, 0.5), - Position = UDim2.fromScale(0.5, 0.5), - Size = UDim2.new(0, 150, 1, 0), - BackgroundTransparency = 1, - }, { - Layout = Roact.createElement("UIListLayout", { - Padding = UDim.new(0, 5), - SortOrder = Enum.SortOrder.LayoutOrder, - FillDirection = Enum.FillDirection.Vertical, - HorizontalAlignment = Enum.HorizontalAlignment.Center, - VerticalAlignment = Enum.VerticalAlignment.Center, - }), - Input0 = Roact.createElement(Helper, { - LayoutOrder = 0, - PlaceholderText = "Enabled", - }), - Input1 = Roact.createElement(Helper, { - LayoutOrder = 1, - Disabled = true, - PlaceholderText = "Disabled", - InitText = "Disabled", - }), - }) - local handle = Roact.mount(element, target) - return function() - Roact.unmount(handle) - end -end diff --git a/src/ThemeContext.lua b/src/ThemeContext.lua deleted file mode 100644 index 75ec897..0000000 --- a/src/ThemeContext.lua +++ /dev/null @@ -1,4 +0,0 @@ -local Packages = script.Parent.Parent -local Roact = require(Packages.Roact) - -return Roact.createContext() \ No newline at end of file diff --git a/src/Tooltip.lua b/src/Tooltip.lua deleted file mode 100644 index 5c83a04..0000000 --- a/src/Tooltip.lua +++ /dev/null @@ -1,199 +0,0 @@ -local TextService = game:GetService("TextService") - -local Packages = script.Parent.Parent - -local Roact = require(Packages.Roact) -local Hooks = require(Packages.RoactHooks) - -local Constants = require(script.Parent.Constants) -local useTheme = require(script.Parent.useTheme) - -local FONT = Constants.Font -local TEXT_SIZE = 14 -local TEXT_PADDING_SIDES = 3 -local TEXT_PADDING_TOP = 1 -local TEXT_PADDING_BTM = 2 - -local OFFSET_RIGHT = 3 -local OFFSET_DOWN = 18 -local OFFSET_LEFT = 2 -local OFFSET_UP = 2 - -local defaultProps = { - Text = "Tooltip.defaultProps.Text", - MaxWidth = 200, - HoverDelay = 0.4, -} - -local function Shadow(props, hooks) - local theme = useTheme(hooks) - return Roact.createElement("Frame", { - BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.DropShadow), - BackgroundTransparency = props.Transparency, - BorderSizePixel = 0, - Position = props.Position, - Size = props.Size, - ZIndex = 0, - }, { - Corner = Roact.createElement("UICorner", { - CornerRadius = UDim.new(0, props.Radius), - }), - Children = Roact.createFragment(props[Roact.Children]), - }) -end -Shadow = Hooks.new(Roact)(Shadow) - -local function Tooltip(props, hooks) - local theme = useTheme(hooks) - local dummyRef = hooks.useValue(Roact.createRef()) - - local display, setDisplay = hooks.useState(false) - local displayPos = hooks.useValue(nil) - local displayThread = hooks.useValue(nil) - - local function cancel() - if display == true then - setDisplay(false) - end - if displayThread.value then - task.cancel(displayThread.value) - displayThread.value = nil - displayPos.value = nil - end - end - - local function onInputBeganChanged(_, input) - if props.Disabled then - return - end - if input.UserInputType == Enum.UserInputType.MouseMovement then - cancel() - displayPos.value = Vector2.new(input.Position.x, input.Position.y) - displayThread.value = task.delay(props.HoverDelay, function() - setDisplay(true) - end) - end - end - - local function onInputEnded(_, input) - if input.UserInputType == Enum.UserInputType.MouseMovement then - cancel() - end - end - - hooks.useEffect(function() - if displayThread.value then - task.cancel(displayThread.value) - end - end, {}) - - local frameSize = Vector2.new(props.MaxWidth - TEXT_PADDING_SIDES * 2, math.huge) - local textSize = TextService:GetTextSize(props.Text, TEXT_SIZE, FONT, frameSize) - local fullSize = textSize + Vector2.new(TEXT_PADDING_SIDES * 2, TEXT_PADDING_BTM + TEXT_PADDING_TOP) - local buffer = 3 -- extra space required around/above/below - - local target = nil - local anchor = Vector2.new(0, 0) - local offset = Vector2.new(OFFSET_RIGHT, OFFSET_DOWN) - if display and dummyRef.value then - local inst = dummyRef.value:getValue() - if inst ~= nil then - target = inst:FindFirstAncestorWhichIsA("LayerCollector") - local mouse = displayPos.value - if target ~= nil then - local spaceRight = target.AbsoluteSize.x - mouse.x - OFFSET_RIGHT - local spaceLeft = mouse.x - OFFSET_LEFT - if spaceRight < fullSize.x + buffer and spaceLeft > spaceRight then - anchor = Vector2.new(1, anchor.y) - offset = Vector2.new(-OFFSET_LEFT, offset.y) - end - local spaceBelow = target.AbsoluteSize.y - mouse.y - OFFSET_DOWN - local spaceAbove = mouse.y - OFFSET_UP - if spaceBelow < fullSize.y + buffer and spaceAbove > spaceBelow then - anchor = Vector2.new(anchor.x, 1) - offset = Vector2.new(offset.x, -OFFSET_UP) - end - end - end - end - - local dropShadow = nil - if target ~= nil then - dropShadow = Roact.createElement(Shadow, { - Position = UDim2.fromOffset(4, 4), - Size = UDim2.new(1, 1, 1, 1), - Radius = 5, - Transparency = 0.96, - }, { - Shadow = Roact.createElement(Shadow, { - Position = UDim2.fromOffset(1, 1), - Size = UDim2.new(1, -2, 1, -2), - Radius = 4, - Transparency = 0.88, - }, { - Shadow = Roact.createElement(Shadow, { - Position = UDim2.fromOffset(1, 1), - Size = UDim2.new(1, -2, 1, -2), - Radius = 3, - Transparency = 0.80, - }, { - Shadow = Roact.createElement(Shadow, { - Position = UDim2.fromOffset(1, 1), - Size = UDim2.new(1, -2, 1, -2), - Radius = 2, - Transparency = 0.77, - }), - }), - }), - }) - end - - return Roact.createElement("Frame", { - Size = UDim2.fromScale(1, 1), - BackgroundTransparency = 1, - [Roact.Ref] = dummyRef.value, - [Roact.Event.InputBegan] = onInputBeganChanged, - [Roact.Event.InputChanged] = onInputBeganChanged, - [Roact.Event.InputEnded] = onInputEnded, - [Roact.Change.AbsolutePosition] = cancel, - }, { - Portal = target and Roact.createElement(Roact.Portal, { - target = target, - }, { - Tooltip = Roact.createElement("Frame", { - ZIndex = Constants.ZIndex.Tooltip, - BackgroundTransparency = 1, - Size = UDim2.fromOffset(fullSize.x, fullSize.y), - AnchorPoint = anchor, - Position = UDim2.fromOffset(displayPos.value.x + offset.x, displayPos.value.y + offset.y), - }, { - Label = Roact.createElement("TextLabel", { - ZIndex = 1, - Size = UDim2.fromScale(1, 1), - BackgroundTransparency = 0, - Text = props.Text, - TextXAlignment = Enum.TextXAlignment.Left, - TextYAlignment = Enum.TextYAlignment.Top, - Font = Enum.Font.SourceSans, - TextSize = 14, - TextWrapped = true, - BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Tooltip), - BorderColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Border), - TextColor3 = theme:GetColor(Enum.StudioStyleGuideColor.MainText), - }, { - Padding = Roact.createElement("UIPadding", { - PaddingLeft = UDim.new(0, TEXT_PADDING_SIDES), - PaddingRight = UDim.new(0, TEXT_PADDING_SIDES), - PaddingTop = UDim.new(0, TEXT_PADDING_TOP), - PaddingBottom = UDim.new(0, TEXT_PADDING_BTM), - }), - }), - Shadow = dropShadow, - }), - }), - }) -end - -return Hooks.new(Roact)(Tooltip, { - defaultProps = defaultProps, -}) diff --git a/src/Tooltip.story.lua b/src/Tooltip.story.lua deleted file mode 100644 index ecba80c..0000000 --- a/src/Tooltip.story.lua +++ /dev/null @@ -1,110 +0,0 @@ -local Packages = script.Parent.Parent -local Roact = require(Packages.Roact) - -local Tooltip = require(script.Parent.Tooltip) - -local Button = require(script.Parent.Button) -local Checkbox = require(script.Parent.Checkbox) -local Dropdown = require(script.Parent.Dropdown) -local Label = require(script.Parent.Label) -local ScrollFrame = require(script.Parent.ScrollFrame) -local RadioButton = require(script.Parent.RadioButton) - -return function(target) - local scrollContents = {} - for i = 1, 10 do - scrollContents[i] = Roact.createElement(Label, { - LayoutOrder = i, - Size = UDim2.new(1, 0, 0, 20), - Text = "Label " .. i, - }, { - Tooltip = Roact.createElement(Tooltip, { - Text = "Tooltip for Label " .. i, - }), - }) - end - - local element = Roact.createFragment({ - Layout = Roact.createElement("UIListLayout", { - Padding = UDim.new(0, 10), - SortOrder = Enum.SortOrder.LayoutOrder, - FillDirection = Enum.FillDirection.Vertical, - HorizontalAlignment = Enum.HorizontalAlignment.Center, - VerticalAlignment = Enum.VerticalAlignment.Center, - }), - - Button = Roact.createElement(Button, { - LayoutOrder = 0, - AnchorPoint = Vector2.new(0.5, 0.5), - Position = UDim2.fromScale(0.5, 0.5), - Size = UDim2.fromOffset(200, 40), - Text = "Example button", - }, { - Tooltip = Roact.createElement(Tooltip, { - Text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", - }), - }), - - Checkbox = Roact.createElement("Frame", { - LayoutOrder = 1, - Size = UDim2.fromOffset(120, 16), - BackgroundTransparency = 1, - }, { - Actual = Roact.createElement(Checkbox, { - Value = true, - Label = "Example checkbox", - OnActivated = function() end, - }, { - Tooltip = Roact.createElement(Tooltip, { - Text = "This is an explanation of the checkbox", - }), - }), - }), - - Dropdown = Roact.createElement(Dropdown, { - LayoutOrder = 2, - Width = UDim.new(0, 120), - Items = { "OptionA", "OptionB", "OptionC", "OptionD", "OptionE", "OptionF" }, - MaxVisibleRows = 4, - SelectedItem = "OptionA", - OnItemSelected = function() end, - }, { - Tooltip = Roact.createElement(Tooltip, { - Text = "This is an explanation of the dropdown", - }), - }), - - Label = Roact.createElement(Label, { - LayoutOrder = 3, - Size = UDim2.fromOffset(80, 14), - Text = "Example label", - }, { - Tooltip = Roact.createElement(Tooltip, { - Text = "This is an explanation of the label", - }), - }), - - ScrollFrame = Roact.createElement(ScrollFrame, { - LayoutOrder = 4, - Size = UDim2.fromOffset(175, 80), - Layout = { - Padding = UDim.new(0, 0), - }, - }, scrollContents), - - RadioButton = Roact.createElement(RadioButton, { - LayoutOrder = 5, - Value = false, - Label = "Example radiobutton", - OnActivated = function() end, - }, { - Tooltip = Roact.createElement(Tooltip, { - Text = "This is an explanation of the radiobutton", - }), - }), - }) - local handle = Roact.mount(element, target) - return function() - Roact.unmount(handle) - end -end diff --git a/src/VerticalCollapsibleSection.story.lua b/src/VerticalCollapsibleSection.story.lua deleted file mode 100644 index 773f94d..0000000 --- a/src/VerticalCollapsibleSection.story.lua +++ /dev/null @@ -1,49 +0,0 @@ -local Packages = script.Parent.Parent -local Roact = require(Packages.Roact) - -local Label = require(script.Parent.Label) -local VerticalCollapsibleSection = require(script.Parent.VerticalCollapsibleSection) - -local Wrapper = Roact.Component:extend("VerticalCollapsibleSectionWrapper") - -function Wrapper:init() - self:setState({ - Collapsed = false, - }) -end - -function Wrapper:render() - local children = {} - for i = 1, 5 do - children[i] = Roact.createElement(Label, { - LayoutOrder = i, - Size = UDim2.new(1, 0, 0, 24), - Text = string.format("Entry%i", i), - BackgroundTransparency = 0, - BorderSizePixel = 0, - BackgroundColor3 = Color3.fromHSV(0, 0, 0.2 - (i % 2) * 0.02), - TextXAlignment = Enum.TextXAlignment.Left, - }, { - Padding = Roact.createElement("UIPadding", { - PaddingLeft = UDim.new(0, 24), - }), - }) - end - return Roact.createElement(VerticalCollapsibleSection, { - HeaderText = "Header", - Collapsed = self.state.Collapsed, - OnToggled = function() - self:setState({ - Collapsed = not self.state.Collapsed, - }) - end, - }, children) -end - -return function(target) - local element = Roact.createElement(Wrapper) - local handle = Roact.mount(element, target) - return function() - Roact.unmount(handle) - end -end diff --git a/src/VerticalCollapsibleSection/CollapsibleSectionHeader.lua b/src/VerticalCollapsibleSection/CollapsibleSectionHeader.lua deleted file mode 100644 index 8efd628..0000000 --- a/src/VerticalCollapsibleSection/CollapsibleSectionHeader.lua +++ /dev/null @@ -1,67 +0,0 @@ -local Packages = script.Parent.Parent.Parent -local Roact = require(Packages.Roact) - -local withTheme = require(script.Parent.Parent.withTheme) - -local Label = require(script.Parent.Parent.Label) -local CollapsibleSectionHeader = Roact.Component:extend("CollapsibleSectionHeader") - -local Constants = require(script.Parent.Parent.Constants) -local HEADER_HEIGHT = 24 - -function CollapsibleSectionHeader:init() - self:setState({ Hover = false }) - self.onInputBegan = function(_, inputObject) - if inputObject.UserInputType == Enum.UserInputType.MouseMovement then - self:setState({ Hover = true }) - elseif inputObject.UserInputType == Enum.UserInputType.MouseButton1 then - self.props.OnToggled() - end - end - self.onInputEnded = function(_, inputObject) - if inputObject.UserInputType == Enum.UserInputType.MouseMovement then - self:setState({ Hover = false }) - end - end -end - -function CollapsibleSectionHeader:render() - local modifier = Enum.StudioStyleGuideModifier.Default - if self.state.Hover then - modifier = Enum.StudioStyleGuideModifier.Hover - end - return withTheme(function(theme) - return Roact.createElement("Frame", { - Active = true, - LayoutOrder = 0, - Size = UDim2.new(1, 0, 0, HEADER_HEIGHT), - BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.HeaderSection, modifier), - BorderColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Border), - [Roact.Event.InputBegan] = self.onInputBegan, - [Roact.Event.InputEnded] = self.onInputEnded, - }, { - Icon = Roact.createElement("ImageLabel", { - AnchorPoint = Vector2.new(0, 0.5), - Position = UDim2.new(0, 7, 0.5, 0), - Size = UDim2.fromOffset(10, 10), - Image = "rbxassetid://5607705156", - ImageColor3 = Color3.fromRGB(170, 170, 170), - ImageRectOffset = Vector2.new(self.props.Collapsed and 0 or 10, 0), - ImageRectSize = Vector2.new(10, 10), - BackgroundTransparency = 1, - }), - Label = Roact.createElement(Label, { - TextColorStyle = Enum.StudioStyleGuideColor.BrightText, - TextXAlignment = Enum.TextXAlignment.Left, - Font = Constants.FontBold, - Text = self.props.Text, - }, { - Padding = Roact.createElement("UIPadding", { - PaddingLeft = UDim.new(0, 24), - }), - }), - }) - end) -end - -return CollapsibleSectionHeader diff --git a/src/VerticalCollapsibleSection/init.lua b/src/VerticalCollapsibleSection/init.lua deleted file mode 100644 index 828792d..0000000 --- a/src/VerticalCollapsibleSection/init.lua +++ /dev/null @@ -1,37 +0,0 @@ -local Packages = script.Parent.Parent -local Roact = require(Packages.Roact) - -local VerticalExpandingList = require(script.Parent.VerticalExpandingList) - -local CollapsibleSectionHeader = require(script.CollapsibleSectionHeader) -local VerticalCollapsibleSection = Roact.Component:extend("VerticalCollapsibleSection") - -VerticalCollapsibleSection.defaultProps = { - LayoutOrder = 0, - ZIndex = 0, - Collapsed = false, - HeaderText = "VerticalCollapsibleSection.defaultProps.HeaderText", - -- OnToggle must exist -} - -function VerticalCollapsibleSection:init() end - -function VerticalCollapsibleSection:render() - return Roact.createElement(VerticalExpandingList, { - LayoutOrder = self.props.LayoutOrder, - ZIndex = self.props.ZIndex, - Padding = 1, - }, { - Header = Roact.createElement(CollapsibleSectionHeader, { - Text = self.props.HeaderText, - Collapsed = self.props.Collapsed, - OnToggled = self.props.OnToggled, - }), - Content = not self.props.Collapsed and Roact.createElement(VerticalExpandingList, { - LayoutOrder = 1, - BorderSizePixel = 0, - }, self.props[Roact.Children]), - }) -end - -return VerticalCollapsibleSection diff --git a/src/VerticalExpandingList.lua b/src/VerticalExpandingList.lua deleted file mode 100644 index 1ccd708..0000000 --- a/src/VerticalExpandingList.lua +++ /dev/null @@ -1,51 +0,0 @@ -local Packages = script.Parent.Parent -local Roact = require(Packages.Roact) - -local withTheme = require(script.Parent.withTheme) - -local VerticalExpandingList = Roact.Component:extend("VerticalExpandingList") - -VerticalExpandingList.defaultProps = { - LayoutOrder = 0, - ZIndex = 0, - BackgroundTransparency = 0, - BackgroundColorStyle = Enum.StudioStyleGuideColor.MainBackground, - BorderSizePixel = 1, - BorderColorStyle = Enum.StudioStyleGuideColor.Border, - Padding = 0, -} - -function VerticalExpandingList:init() - self.contentSize, self.setContentSize = Roact.createBinding(Vector2.new()) -end - -function VerticalExpandingList:render() - return withTheme(function(theme) - return Roact.createElement("Frame", { - LayoutOrder = self.props.LayoutOrder, - ZIndex = self.props.ZIndex, - AnchorPoint = Vector2.new(0, 0), - Position = UDim2.fromScale(0, 0), - Size = self.contentSize:map(function(size) - return UDim2.new(1, 0, 0, size.y + self.props.BorderSizePixel * 2) - end), - BackgroundTransparency = self.props.BackgroundTransparency, - BackgroundColor3 = theme:GetColor(self.props.BackgroundColorStyle), - BorderSizePixel = self.props.BorderSizePixel, - BorderColor3 = theme:GetColor(self.props.BorderColorStyle), - BorderMode = Enum.BorderMode.Inset, - }, { - Layout = Roact.createElement("UIListLayout", { - SortOrder = Enum.SortOrder.LayoutOrder, - FillDirection = Enum.FillDirection.Vertical, - Padding = UDim.new(0, self.props.Padding), - [Roact.Change.AbsoluteContentSize] = function(rbx) - self.setContentSize(rbx.AbsoluteContentSize) - end, - }), - Children = Roact.createFragment(self.props[Roact.Children]), - }) - end) -end - -return VerticalExpandingList diff --git a/src/VerticalExpandingList.story.lua b/src/VerticalExpandingList.story.lua deleted file mode 100644 index 20aec32..0000000 --- a/src/VerticalExpandingList.story.lua +++ /dev/null @@ -1,63 +0,0 @@ -local Packages = script.Parent.Parent -local Roact = require(Packages.Roact) - -local Button = require(script.Parent.Button) -local Label = require(script.Parent.Label) -local VerticalExpandingList = require(script.Parent.VerticalExpandingList) - -local Wrapper = Roact.Component:extend("VerticalExpandingListWrapper") - -function Wrapper:init() - self:setState({ - Count = 1, - }) -end - -function Wrapper:render() - local children = {} - for i = 1, self.state.Count do - children[i] = Roact.createElement(Label, { - LayoutOrder = i, - Size = UDim2.new(1, 0, 0, 32), - Text = string.format("Label%i", i), - }) - end - return Roact.createFragment({ - Layout = Roact.createElement("UIListLayout", { - SortOrder = Enum.SortOrder.LayoutOrder, - Padding = UDim.new(0, 5), - }), - ButtonRemove = Roact.createElement(Button, { - LayoutOrder = 0, - Text = "Remove Child", - Size = UDim2.fromOffset(120, 30), - Disabled = self.state.Count <= 0, - OnActivated = function() - self:setState({ - Count = math.max(0, self.state.Count - 1), - }) - end, - }), - ExpandingList = Roact.createElement(VerticalExpandingList, { - LayoutOrder = 1, - }, children), - ButtonAdd = Roact.createElement(Button, { - LayoutOrder = 2, - Text = "Add Child", - Size = UDim2.fromOffset(120, 30), - OnActivated = function() - self:setState({ - Count = self.state.Count + 1, - }) - end, - }), - }) -end - -return function(target) - local element = Roact.createElement(Wrapper) - local handle = Roact.mount(element, target) - return function() - Roact.unmount(handle) - end -end diff --git a/src/Widget.lua b/src/Widget.lua deleted file mode 100644 index dee9e9c..0000000 --- a/src/Widget.lua +++ /dev/null @@ -1,71 +0,0 @@ -local Packages = script.Parent.Parent -local Roact = require(Packages.Roact) - -local plugin = script:FindFirstAncestorWhichIsA("Plugin") -local withTheme = require(script.Parent.withTheme) - -local Widget = Roact.Component:extend("Widget") - -Widget.defaultProps = { - Title = "Widget.defaultProps.Title", - Name = "Widget.defaultProps.Name", - InitialDockState = Enum.InitialDockState.Float, - FloatingWindowSize = Vector2.new(300, 200), - MinimumWindowSize = Vector2.new(0, 0), - OnClosed = function() end, -} - -function Widget:init() - local initProps = self.props - - local id = initProps.Id - local info = DockWidgetPluginGuiInfo.new( - initProps.InitialDockState, - true, -- InitialEnabled (TODO) - true, -- InitialEnabledShouldOverrideRestore (TODO) - initProps.FloatingWindowSize.x, - initProps.FloatingWindowSize.y, - initProps.MinimumWindowSize.x, - initProps.MinimumWindowSize.y - ) - - local widget = plugin:CreateDockWidgetPluginGui(id, info) - widget.Name = initProps.Name - widget.Title = initProps.Title - widget.ZIndexBehavior = Enum.ZIndexBehavior.Sibling - - widget:BindToClose(function() - widget.Enabled = false - self.props.OnClosed() - end) - - self.widget = widget -end - -function Widget:willUnmount() - self.widget:Destroy() - self.widget = nil -end - -function Widget:didUpdate(prevProps) - local nextProps = self.props - if prevProps.Title ~= nextProps.Title then - self.widget.Title = nextProps.Title -- TODO: clean this up - end -end - -function Widget:render() - return Roact.createElement(Roact.Portal, { - target = self.widget, - }, { - Main = withTheme(function(theme) - return Roact.createElement("Frame", { - BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.MainBackground), - BorderSizePixel = 0, - Size = UDim2.fromScale(1, 1), - }, self.props[Roact.Children]) - end), - }) -end - -return Widget diff --git a/src/Widget.story.lua b/src/Widget.story.lua deleted file mode 100644 index 9c4085a..0000000 --- a/src/Widget.story.lua +++ /dev/null @@ -1,41 +0,0 @@ -local Packages = script.Parent.Parent -local Roact = require(Packages.Roact) - -local Button = require(script.Parent.Button) -local Widget = require(script.Parent.Widget) - -local Wrapper = Roact.Component:extend("Wrapper") - -function Wrapper:init() - self:setState({ - Enabled = false, - }) -end - -function Wrapper:render() - return Roact.createFragment({ - Button = Roact.createElement(Button, { - AnchorPoint = Vector2.new(0.5, 0.5), - Position = UDim2.fromScale(0.5, 0.5), - Size = UDim2.fromOffset(120, 30), - Text = self.state.Enabled and "Close Widget" or "Open Widget", - OnActivated = function() - self:setState({ Enabled = not self.state.Enabled }) - end, - }), - Widget = self.state.Enabled and Roact.createElement(Widget, { - Id = "_unique101", - OnClosed = function() - self:setState({ Enabled = false }) - end, - }), - }) -end - -return function(target) - local element = Roact.createElement(Wrapper) - local handle = Roact.mount(element, target) - return function() - Roact.unmount(handle) - end -end diff --git a/src/getTextSize.luau b/src/getTextSize.luau new file mode 100644 index 0000000..9e4bd47 --- /dev/null +++ b/src/getTextSize.luau @@ -0,0 +1,14 @@ +local TextService = game:GetService("TextService") + +local Constants = require("./Constants") + +local TEXT_SIZE = Constants.DefaultTextSize +local FONT = Constants.DefaultFont +local FRAME_SIZE = Vector2.one * math.huge + +local function getTextSize(text: string) + local size = TextService:GetTextSize(text, TEXT_SIZE, FONT, FRAME_SIZE) + return Vector2.new(math.ceil(size.X), math.ceil(size.Y)) + Vector2.one +end + +return getTextSize diff --git a/src/init.lua b/src/init.lua deleted file mode 100644 index 5013a46..0000000 --- a/src/init.lua +++ /dev/null @@ -1,28 +0,0 @@ -return { - Background = require(script.Background), - BaseButton = require(script.BaseButton), - Button = require(script.Button), - Checkbox = require(script.Checkbox), - ColorPicker = require(script.ColorPicker), - Dropdown = require(script.Dropdown), - Label = require(script.Label), - MainButton = require(script.MainButton), - RadioButton = require(script.RadioButton), - ScrollFrame = require(script.ScrollFrame), - Slider = require(script.Slider), - Splitter = require(script.Splitter), - TabContainer = require(script.TabContainer), - TextInput = require(script.TextInput), - Tooltip = require(script.Tooltip), - VerticalCollapsibleSection = require(script.VerticalCollapsibleSection), - VerticalExpandingList = require(script.VerticalExpandingList), - Widget = require(script.Widget), - - Constants = require(script.Constants), - ThemeContext = require(script.ThemeContext), - PluginProvider = require(script.PluginProvider), - - withTheme = require(script.withTheme), - useTheme = require(script.useTheme), - usePlugin = require(script.usePlugin), -} diff --git a/src/init.luau b/src/init.luau new file mode 100644 index 0000000..51f85f0 --- /dev/null +++ b/src/init.luau @@ -0,0 +1,28 @@ +return { + Constants = require("./Constants"), + + Background = require("./Components/Background"), + Button = require("./Components/Button"), + Checkbox = require("./Components/Checkbox"), + ColorPicker = require("./Components/ColorPicker"), + Dropdown = require("./Components/Dropdown"), + DropShadowFrame = require("./Components/DropShadowFrame"), + Label = require("./Components/Label"), + LoadingDots = require("./Components/LoadingDots"), + MainButton = require("./Components/MainButton"), + NumberSequencePicker = require("./Components/NumberSequencePicker"), + NumericInput = require("./Components/NumericInput"), + PluginProvider = require("./Components/PluginProvider"), + ProgressBar = require("./Components/ProgressBar"), + RadioButton = require("./Components/RadioButton"), + ScrollFrame = require("./Components/ScrollFrame"), + Slider = require("./Components/Slider"), + Splitter = require("./Components/Splitter"), + TabContainer = require("./Components/TabContainer"), + TextInput = require("./Components/TextInput"), + + ThemeContext = require("./Contexts/ThemeContext"), + + useTheme = require("./Hooks/useTheme"), + useMouseIcon = require("./Hooks/useMouseIcon"), +} diff --git a/src/joinDictionaries.lua b/src/joinDictionaries.lua deleted file mode 100644 index 5502b4a..0000000 --- a/src/joinDictionaries.lua +++ /dev/null @@ -1,18 +0,0 @@ -local Packages = script.Parent.Parent -local Roact = require(Packages.Roact) - -local function joinDictionaries(...) - local out = {} - for i = 1, select("#", ...) do - for key, val in pairs(select(i, ...)) do - if val == Roact.None then - out[key] = nil - else - out[key] = val - end - end - end - return out -end - -return joinDictionaries diff --git a/src/useDragInput.lua b/src/useDragInput.lua deleted file mode 100644 index b73999d..0000000 --- a/src/useDragInput.lua +++ /dev/null @@ -1,75 +0,0 @@ -local UserInputService = game:GetService("UserInputService") -local RunService = game:GetService("RunService") - -local function useDragInput(hooks, callback) - local hovered, setHovered = hooks.useState(false) - local active, setActive = hooks.useState(false) - - local globalConnection = hooks.useValue(nil) - local function cleanup() - if globalConnection.value then - globalConnection.value:Disconnect() - end - end - - -- prevent stale values in callback - local savedCallback = hooks.useValue(callback) - hooks.useEffect(function() - savedCallback.value = callback - end, { callback }) - - hooks.useEffect(function() - return cleanup - end, {}) - - local function onInputBegan(rbx, input) - if input.UserInputType == Enum.UserInputType.MouseMovement then - setHovered(true) - elseif input.UserInputType == Enum.UserInputType.MouseButton1 and not active then - local widget = rbx:FindFirstAncestorWhichIsA("DockWidgetPluginGui") - if widget ~= nil then - globalConnection.value = RunService.RenderStepped:Connect(function() - savedCallback.value(rbx, widget:GetRelativeMousePosition()) - end) - else - globalConnection.value = UserInputService.InputChanged:Connect(function(globalInput) - savedCallback.value(rbx, Vector2.new(globalInput.Position.x, globalInput.Position.y)) - end) - end - setActive(true) - savedCallback.value(rbx, Vector2.new(input.Position.x, input.Position.y)) - end - end - - local function onInputEnded(rbx, input) - if input.UserInputType == Enum.UserInputType.MouseMovement then - setHovered(false) - elseif input.UserInputType == Enum.UserInputType.MouseButton1 then - -- ended for mousemovement does not fire if mouse is moved and released quickly enough - -- over another instance, so we manually check if there is still an active hover - local offset = Vector2.new(input.Position.x, input.Position.y) - rbx.AbsolutePosition - local bounds = rbx.AbsoluteSize - if offset.x < 0 or offset.x > bounds.x or offset.y < 0 or offset.y > bounds.y then - setHovered(false) - end - setActive(false) - cleanup() - end - end - - local function cancel() - setHovered(false) - setActive(false) - cleanup() - end - - return { - hovered = hovered, - active = active, - onInputBegan = onInputBegan, - onInputEnded = onInputEnded, - cancel = cancel, - } -end - -return useDragInput diff --git a/src/usePlugin.lua b/src/usePlugin.lua deleted file mode 100644 index bfd83ca..0000000 --- a/src/usePlugin.lua +++ /dev/null @@ -1,7 +0,0 @@ -local PluginContext = require(script.Parent.PluginContext) - -local function usePlugin(hooks) - return hooks.useContext(PluginContext) -end - -return usePlugin diff --git a/src/useTheme.lua b/src/useTheme.lua deleted file mode 100644 index b6e8dd6..0000000 --- a/src/useTheme.lua +++ /dev/null @@ -1,21 +0,0 @@ -local ThemeContext = require(script.Parent.ThemeContext) -local studio = settings().Studio - -local function useTheme(hooks) - local theme = hooks.useContext(ThemeContext) - local studioTheme, setStudioTheme = hooks.useState(studio.Theme) - - hooks.useEffect(function() - if theme then return end - local connection = studio.ThemeChanged:Connect(function() - setStudioTheme(studio.Theme) - end) - return function() - connection:Disconnect() - end - end, { theme, studioTheme }) - - return theme or studioTheme -end - -return useTheme diff --git a/src/withTheme.lua b/src/withTheme.lua deleted file mode 100644 index 459a9d4..0000000 --- a/src/withTheme.lua +++ /dev/null @@ -1,46 +0,0 @@ -local Packages = script.Parent.Parent -local Roact = require(Packages.Roact) -local ThemeContext = require(script.Parent.ThemeContext) - -local StudioThemeProvider = Roact.Component:extend("StudioThemeProvider") -local studioSettings = settings().Studio - -function StudioThemeProvider:init() - self:setState({ studioTheme = studioSettings.Theme }) - - self._changed = studioSettings.ThemeChanged:Connect(function() - self:setState({ studioTheme = studioSettings.Theme }) - end) -end - -function StudioThemeProvider:willUnmount() - self._changed:Disconnect() -end - -function StudioThemeProvider:render() - local render = Roact.oneChild(self.props[Roact.Children]) - - return Roact.createElement(ThemeContext.Provider, { - value = self.state.studioTheme, - }, { - Consumer = Roact.createElement(ThemeContext.Consumer, { - render = render, - }) - }) -end - -local function withTheme(render) - return Roact.createElement(ThemeContext.Consumer, { - render = function(theme) - if theme then - return render(theme) - else - return Roact.createElement(StudioThemeProvider, {}, { - render = render, - }) - end - end - }) -end - -return withTheme diff --git a/stylua.toml b/stylua.toml new file mode 100644 index 0000000..ed2de71 --- /dev/null +++ b/stylua.toml @@ -0,0 +1,2 @@ +[sort_requires] +enabled = true diff --git a/wally.lock b/wally.lock deleted file mode 100644 index c5be9d3..0000000 --- a/wally.lock +++ /dev/null @@ -1,18 +0,0 @@ -# This file is automatically @generated by Wally. -# It is not intended for manual editing. -registry = "test" - -[[package]] -name = "kampfkarren/roact-hooks" -version = "0.5.0" -dependencies = [] - -[[package]] -name = "roblox/roact" -version = "1.4.4" -dependencies = [] - -[[package]] -name = "sircfenner/studiocomponents" -version = "0.1.4" -dependencies = [["Roact", "roblox/roact@1.4.4"], ["RoactHooks", "kampfkarren/roact-hooks@0.5.0"]] diff --git a/wally.toml b/wally.toml deleted file mode 100644 index d3366a8..0000000 --- a/wally.toml +++ /dev/null @@ -1,9 +0,0 @@ -[package] -name = "sircfenner/studiocomponents" -version = "0.1.4" -registry = "https://github.com/UpliftGames/wally-index" -realm = "shared" - -[dependencies] -Roact = "roblox/roact@1.4.4" -RoactHooks = "kampfkarren/roact-hooks@0.5.0" \ No newline at end of file