From f22e148f4959f2014a930b696c01698e5fe0c8ab Mon Sep 17 00:00:00 2001 From: sircfenner Date: Tue, 30 Apr 2024 23:12:14 +0100 Subject: [PATCH] react migration --- .darklua-wally.json | 18 + .darklua.json | 18 + .gitattributes | 7 + .github/pull_request_template.md | 5 + .github/workflows/release.yml | 148 ++++++ .github/workflows/test.yml | 40 ++ .gitignore | 31 +- .luau-analyze.json | 6 + .luaurc | 10 + .moonwave/custom.css | 16 + .../static/components/background/dark.png | Bin 0 -> 451 bytes .../static/components/background/light.png | Bin 0 -> 475 bytes .moonwave/static/components/button/dark.png | Bin 0 -> 2380 bytes .moonwave/static/components/button/light.png | Bin 0 -> 2911 bytes .moonwave/static/components/checkbox/dark.png | Bin 0 -> 2731 bytes .../static/components/checkbox/light.png | Bin 0 -> 3144 bytes .../static/components/colorpicker/dark.png | Bin 0 -> 71380 bytes .../static/components/colorpicker/light.png | Bin 0 -> 17611 bytes .moonwave/static/components/dropdown/dark.png | Bin 0 -> 6938 bytes .../static/components/dropdown/light.png | Bin 0 -> 7218 bytes .../components/dropshadowframe/dark.png | Bin 0 -> 1836 bytes .../components/dropshadowframe/light.png | Bin 0 -> 1929 bytes .moonwave/static/components/label/dark.png | Bin 0 -> 1281 bytes .moonwave/static/components/label/light.png | Bin 0 -> 1552 bytes .../static/components/loadingdots/dark.gif | Bin 0 -> 22909 bytes .../static/components/loadingdots/light.gif | Bin 0 -> 26364 bytes .../static/components/mainbutton/dark.png | Bin 0 -> 3646 bytes .../static/components/mainbutton/light.png | Bin 0 -> 3648 bytes .../components/numbersequencepicker/dark.png | Bin 0 -> 19759 bytes .../components/numbersequencepicker/light.png | Bin 0 -> 17853 bytes .../static/components/numericinput/dark.png | Bin 0 -> 925 bytes .../static/components/numericinput/light.png | Bin 0 -> 865 bytes .../static/components/progressbar/dark.png | Bin 0 -> 2024 bytes .../static/components/progressbar/light.png | Bin 0 -> 2809 bytes .../static/components/radiobutton/dark.png | Bin 0 -> 1223 bytes .../static/components/radiobutton/light.png | Bin 0 -> 1443 bytes .../static/components/scrollframe/dark.png | Bin 0 -> 435 bytes .../static/components/scrollframe/light.png | Bin 0 -> 434 bytes .moonwave/static/components/slider/dark.png | Bin 0 -> 285 bytes .moonwave/static/components/slider/light.png | Bin 0 -> 275 bytes .moonwave/static/components/splitter/dark.png | Bin 0 -> 1363 bytes .../static/components/splitter/light.png | Bin 0 -> 1484 bytes .../static/components/tabcontainer/dark.png | Bin 0 -> 1191 bytes .../static/components/tabcontainer/light.png | Bin 0 -> 1347 bytes .../static/components/textinput/dark.png | Bin 0 -> 1015 bytes .../static/components/textinput/light.png | Bin 0 -> 1336 bytes .npmignore | 27 ++ .styluaignore | 7 + .vscode/extensions.json | 7 + .vscode/settings.json | 9 + CHANGELOG.md | 26 ++ LICENSE | 12 +- README.md | 43 +- aftman.toml | 2 - default.project.json | 6 - develop.project.json | 16 - docs/components/background.md | 28 -- docs/components/button.md | 7 - docs/components/checkbox.md | 7 - docs/components/colorpicker.md | 0 docs/components/dropdown.md | 0 docs/components/label.md | 0 docs/components/mainbutton.md | 0 docs/components/scrollframe.md | 0 docs/components/slider.md | 0 docs/components/textinput.md | 0 docs/components/verticalcollapsiblesection.md | 0 docs/components/verticalexpandinglist.md | 0 docs/components/widget.md | 0 docs/extra.css | 38 -- docs/getting-started.md | 44 ++ docs/guide/installation.md | 17 - docs/guide/usage.md | 20 - docs/img/button/dark/default.png | Bin 606 -> 0 bytes docs/img/button/dark/disabled.png | Bin 469 -> 0 bytes docs/img/button/dark/hovered.png | Bin 600 -> 0 bytes docs/img/button/dark/pressed.png | Bin 611 -> 0 bytes docs/img/button/dark/selected.png | Bin 615 -> 0 bytes docs/img/button/light/default.png | Bin 649 -> 0 bytes docs/img/button/light/disabled.png | Bin 603 -> 0 bytes docs/img/button/light/hovered.png | Bin 703 -> 0 bytes docs/img/button/light/pressed.png | Bin 632 -> 0 bytes docs/img/button/light/selected.png | Bin 703 -> 0 bytes docs/img/checkbox/dark/disabled-false.png | Bin 440 -> 0 bytes .../checkbox/dark/disabled-indeterminate.png | Bin 483 -> 0 bytes docs/img/checkbox/dark/disabled-true.png | Bin 561 -> 0 bytes docs/img/checkbox/dark/false.png | Bin 531 -> 0 bytes docs/img/checkbox/dark/hovered.png | Bin 791 -> 0 bytes docs/img/checkbox/dark/indeterminate.png | Bin 568 -> 0 bytes docs/img/checkbox/dark/true.png | Bin 780 -> 0 bytes docs/img/checkbox/light/disabled-false.png | Bin 519 -> 0 bytes .../checkbox/light/disabled-indeterminate.png | Bin 587 -> 0 bytes docs/img/checkbox/light/disabled-true.png | Bin 678 -> 0 bytes docs/img/checkbox/light/false.png | Bin 550 -> 0 bytes docs/img/checkbox/light/hovered.png | Bin 801 -> 0 bytes docs/img/checkbox/light/indeterminate.png | Bin 604 -> 0 bytes docs/img/checkbox/light/true.png | Bin 786 -> 0 bytes docs/index.md | 24 - docs/intro.md | 49 ++ foreman.toml | 7 + mkdocs.yml | 38 -- model.project.json | 9 + moonwave.toml | 30 ++ package-lock.json | 170 +++++++ package.json | 35 ++ scripts/analyze.sh | 13 + scripts/build-assets.sh | 6 + scripts/build-roblox-model.sh | 35 ++ scripts/build-wally-package.sh | 30 ++ scripts/install-deps.sh | 11 + scripts/npm-to-wally.js | 166 +++++++ scripts/serve.sh | 28 ++ selene.toml | 3 +- selene_defs.yml | 7 + serve.project.json | 11 + src/Background.lua | 22 - src/Background.story.lua | 12 - src/BaseButton.lua | 97 ---- src/Button.lua | 18 - src/Button.story.lua | 39 -- src/Checkbox.lua | 129 ------ src/Checkbox.story.lua | 97 ---- src/ColorPicker.lua | 153 ------- src/ColorPicker.story.lua | 57 --- src/CommonProps.luau | 34 ++ src/Components/Background.luau | 53 +++ src/Components/Button.luau | 77 ++++ src/Components/Checkbox.luau | 129 ++++++ src/Components/ColorPicker.luau | 421 ++++++++++++++++++ src/Components/DropShadowFrame.luau | 108 +++++ src/Components/Dropdown/ClearButton.luau | 49 ++ src/Components/Dropdown/DropdownItem.luau | 94 ++++ src/Components/Dropdown/Types.luau | 42 ++ src/Components/Dropdown/init.luau | 385 ++++++++++++++++ src/Components/Foundation/BaseButton.luau | 133 ++++++ .../Foundation/BaseLabelledToggle.luau | 114 +++++ src/Components/Foundation/BaseTextInput.luau | 191 ++++++++ src/Components/Label.luau | 114 +++++ src/Components/LoadingDots.luau | 107 +++++ src/Components/MainButton.luau | 51 +++ .../NumberSequencePicker/AxisLabel.luau | 30 ++ .../NumberSequencePicker/Constants.luau | 5 + .../NumberSequencePicker/DashedLine.luau | 39 ++ .../NumberSequencePicker/FreeLine.luau | 37 ++ .../LabelledNumericInput.luau | 69 +++ .../NumberSequencePicker/SequenceNode.luau | 264 +++++++++++ src/Components/NumberSequencePicker/init.luau | 416 +++++++++++++++++ src/Components/NumericInput.luau | 335 ++++++++++++++ src/Components/PluginProvider.luau | 94 ++++ src/Components/ProgressBar.luau | 128 ++++++ src/Components/RadioButton.luau | 100 +++++ src/Components/ScrollFrame/Constants.luau | 5 + src/Components/ScrollFrame/ScrollBar.luau | 172 +++++++ .../ScrollFrame/ScrollBarArrow.luau | 129 ++++++ src/Components/ScrollFrame/Types.luau | 12 + src/Components/ScrollFrame/init.luau | 368 +++++++++++++++ src/Components/Slider.luau | 206 +++++++++ src/Components/Splitter.luau | 226 ++++++++++ src/Components/TabContainer.luau | 242 ++++++++++ src/Components/TextInput.luau | 91 ++++ src/Constants.lua | 21 - src/Constants.luau | 69 +++ src/Contexts/PluginContext.luau | 9 + src/Contexts/ThemeContext.luau | 3 + src/Dropdown.story.lua | 80 ---- src/Dropdown/DropdownItem.lua | 55 --- src/Dropdown/init.lua | 214 --------- src/Hooks/useFreshCallback.luau | 21 + src/Hooks/useMouseDrag.luau | 118 +++++ src/Hooks/useMouseIcon.luau | 92 ++++ src/Hooks/useTheme.luau | 48 ++ src/Label.lua | 41 -- src/Label.story.lua | 48 -- src/MainButton.lua | 18 - src/MainButton.story.lua | 39 -- src/PluginContext.lua | 4 - src/PluginProvider.lua | 47 -- src/RadioButton.lua | 108 ----- src/RadioButton.story.lua | 48 -- src/ScrollFrame.story.lua | 130 ------ src/ScrollFrame/Constants.lua | 4 - src/ScrollFrame/ScrollArrow.lua | 122 ----- src/ScrollFrame/ScrollBarHandle.lua | 95 ---- src/ScrollFrame/init.lua | 338 -------------- src/Slider.lua | 136 ------ src/Slider.story.lua | 82 ---- src/Splitter.lua | 149 ------- src/Splitter.story.lua | 93 ---- src/Stories/Background.story.luau | 10 + src/Stories/Button.story.luau | 90 ++++ src/Stories/Checkbox.story.luau | 71 +++ src/Stories/ColorPicker.story.luau | 23 + src/Stories/DropShadowFrame.story.luau | 42 ++ src/Stories/Dropdown.story.luau | 81 ++++ src/Stories/Helpers/createStory.luau | 101 +++++ src/Stories/Helpers/getStoryPlugin.luau | 9 + src/Stories/Label.story.luau | 56 +++ src/Stories/LoadingDots.story.luau | 10 + src/Stories/MainButton.story.luau | 90 ++++ src/Stories/NumberSequencePicker.story.luau | 28 ++ src/Stories/NumericInput.story.luau | 79 ++++ src/Stories/ProgressBar.story.luau | 64 +++ src/Stories/RadioButton.story.luau | 27 ++ src/Stories/ScrollFrame.story.luau | 76 ++++ src/Stories/Slider.story.luau | 33 ++ src/Stories/Splitter.story.luau | 70 +++ src/Stories/TabContainer.story.luau | 63 +++ src/Stories/TextInput.story.luau | 55 +++ src/TabContainer.story.lua | 80 ---- src/TabContainer/TabButton.lua | 74 --- src/TabContainer/init.lua | 87 ---- src/TextInput.lua | 176 -------- src/TextInput.story.lua | 48 -- src/ThemeContext.lua | 4 - src/Tooltip.lua | 199 --------- src/Tooltip.story.lua | 110 ----- src/VerticalCollapsibleSection.story.lua | 49 -- .../CollapsibleSectionHeader.lua | 67 --- src/VerticalCollapsibleSection/init.lua | 37 -- src/VerticalExpandingList.lua | 51 --- src/VerticalExpandingList.story.lua | 63 --- src/Widget.lua | 71 --- src/Widget.story.lua | 41 -- src/getTextSize.luau | 14 + src/init.lua | 28 -- src/init.luau | 28 ++ src/joinDictionaries.lua | 18 - src/useDragInput.lua | 75 ---- src/usePlugin.lua | 7 - src/useTheme.lua | 21 - src/withTheme.lua | 46 -- stylua.toml | 2 + wally.lock | 18 - wally.toml | 9 - 234 files changed, 7616 insertions(+), 4098 deletions(-) create mode 100644 .darklua-wally.json create mode 100644 .darklua.json create mode 100644 .gitattributes create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/test.yml create mode 100644 .luau-analyze.json create mode 100644 .luaurc create mode 100644 .moonwave/custom.css create mode 100644 .moonwave/static/components/background/dark.png create mode 100644 .moonwave/static/components/background/light.png create mode 100644 .moonwave/static/components/button/dark.png create mode 100644 .moonwave/static/components/button/light.png create mode 100644 .moonwave/static/components/checkbox/dark.png create mode 100644 .moonwave/static/components/checkbox/light.png create mode 100644 .moonwave/static/components/colorpicker/dark.png create mode 100644 .moonwave/static/components/colorpicker/light.png create mode 100644 .moonwave/static/components/dropdown/dark.png create mode 100644 .moonwave/static/components/dropdown/light.png create mode 100644 .moonwave/static/components/dropshadowframe/dark.png create mode 100644 .moonwave/static/components/dropshadowframe/light.png create mode 100644 .moonwave/static/components/label/dark.png create mode 100644 .moonwave/static/components/label/light.png create mode 100644 .moonwave/static/components/loadingdots/dark.gif create mode 100644 .moonwave/static/components/loadingdots/light.gif create mode 100644 .moonwave/static/components/mainbutton/dark.png create mode 100644 .moonwave/static/components/mainbutton/light.png create mode 100644 .moonwave/static/components/numbersequencepicker/dark.png create mode 100644 .moonwave/static/components/numbersequencepicker/light.png create mode 100644 .moonwave/static/components/numericinput/dark.png create mode 100644 .moonwave/static/components/numericinput/light.png create mode 100644 .moonwave/static/components/progressbar/dark.png create mode 100644 .moonwave/static/components/progressbar/light.png create mode 100644 .moonwave/static/components/radiobutton/dark.png create mode 100644 .moonwave/static/components/radiobutton/light.png create mode 100644 .moonwave/static/components/scrollframe/dark.png create mode 100644 .moonwave/static/components/scrollframe/light.png create mode 100644 .moonwave/static/components/slider/dark.png create mode 100644 .moonwave/static/components/slider/light.png create mode 100644 .moonwave/static/components/splitter/dark.png create mode 100644 .moonwave/static/components/splitter/light.png create mode 100644 .moonwave/static/components/tabcontainer/dark.png create mode 100644 .moonwave/static/components/tabcontainer/light.png create mode 100644 .moonwave/static/components/textinput/dark.png create mode 100644 .moonwave/static/components/textinput/light.png create mode 100644 .npmignore create mode 100644 .styluaignore create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json create mode 100644 CHANGELOG.md delete mode 100644 aftman.toml delete mode 100644 default.project.json delete mode 100644 develop.project.json delete mode 100644 docs/components/background.md delete mode 100644 docs/components/button.md delete mode 100644 docs/components/checkbox.md delete mode 100644 docs/components/colorpicker.md delete mode 100644 docs/components/dropdown.md delete mode 100644 docs/components/label.md delete mode 100644 docs/components/mainbutton.md delete mode 100644 docs/components/scrollframe.md delete mode 100644 docs/components/slider.md delete mode 100644 docs/components/textinput.md delete mode 100644 docs/components/verticalcollapsiblesection.md delete mode 100644 docs/components/verticalexpandinglist.md delete mode 100644 docs/components/widget.md delete mode 100644 docs/extra.css create mode 100644 docs/getting-started.md delete mode 100644 docs/guide/installation.md delete mode 100644 docs/guide/usage.md delete mode 100644 docs/img/button/dark/default.png delete mode 100644 docs/img/button/dark/disabled.png delete mode 100644 docs/img/button/dark/hovered.png delete mode 100644 docs/img/button/dark/pressed.png delete mode 100644 docs/img/button/dark/selected.png delete mode 100644 docs/img/button/light/default.png delete mode 100644 docs/img/button/light/disabled.png delete mode 100644 docs/img/button/light/hovered.png delete mode 100644 docs/img/button/light/pressed.png delete mode 100644 docs/img/button/light/selected.png delete mode 100644 docs/img/checkbox/dark/disabled-false.png delete mode 100644 docs/img/checkbox/dark/disabled-indeterminate.png delete mode 100644 docs/img/checkbox/dark/disabled-true.png delete mode 100644 docs/img/checkbox/dark/false.png delete mode 100644 docs/img/checkbox/dark/hovered.png delete mode 100644 docs/img/checkbox/dark/indeterminate.png delete mode 100644 docs/img/checkbox/dark/true.png delete mode 100644 docs/img/checkbox/light/disabled-false.png delete mode 100644 docs/img/checkbox/light/disabled-indeterminate.png delete mode 100644 docs/img/checkbox/light/disabled-true.png delete mode 100644 docs/img/checkbox/light/false.png delete mode 100644 docs/img/checkbox/light/hovered.png delete mode 100644 docs/img/checkbox/light/indeterminate.png delete mode 100644 docs/img/checkbox/light/true.png delete mode 100644 docs/index.md create mode 100644 docs/intro.md create mode 100644 foreman.toml delete mode 100644 mkdocs.yml create mode 100644 model.project.json create mode 100644 moonwave.toml create mode 100644 package-lock.json create mode 100644 package.json create mode 100755 scripts/analyze.sh create mode 100755 scripts/build-assets.sh create mode 100755 scripts/build-roblox-model.sh create mode 100755 scripts/build-wally-package.sh create mode 100755 scripts/install-deps.sh create mode 100644 scripts/npm-to-wally.js create mode 100755 scripts/serve.sh create mode 100644 selene_defs.yml create mode 100644 serve.project.json delete mode 100644 src/Background.lua delete mode 100644 src/Background.story.lua delete mode 100644 src/BaseButton.lua delete mode 100644 src/Button.lua delete mode 100644 src/Button.story.lua delete mode 100644 src/Checkbox.lua delete mode 100644 src/Checkbox.story.lua delete mode 100644 src/ColorPicker.lua delete mode 100644 src/ColorPicker.story.lua create mode 100644 src/CommonProps.luau create mode 100644 src/Components/Background.luau create mode 100644 src/Components/Button.luau create mode 100644 src/Components/Checkbox.luau create mode 100644 src/Components/ColorPicker.luau create mode 100644 src/Components/DropShadowFrame.luau create mode 100644 src/Components/Dropdown/ClearButton.luau create mode 100644 src/Components/Dropdown/DropdownItem.luau create mode 100644 src/Components/Dropdown/Types.luau create mode 100644 src/Components/Dropdown/init.luau create mode 100644 src/Components/Foundation/BaseButton.luau create mode 100644 src/Components/Foundation/BaseLabelledToggle.luau create mode 100644 src/Components/Foundation/BaseTextInput.luau create mode 100644 src/Components/Label.luau create mode 100644 src/Components/LoadingDots.luau create mode 100644 src/Components/MainButton.luau create mode 100644 src/Components/NumberSequencePicker/AxisLabel.luau create mode 100644 src/Components/NumberSequencePicker/Constants.luau create mode 100644 src/Components/NumberSequencePicker/DashedLine.luau create mode 100644 src/Components/NumberSequencePicker/FreeLine.luau create mode 100644 src/Components/NumberSequencePicker/LabelledNumericInput.luau create mode 100644 src/Components/NumberSequencePicker/SequenceNode.luau create mode 100644 src/Components/NumberSequencePicker/init.luau create mode 100644 src/Components/NumericInput.luau create mode 100644 src/Components/PluginProvider.luau create mode 100644 src/Components/ProgressBar.luau create mode 100644 src/Components/RadioButton.luau create mode 100644 src/Components/ScrollFrame/Constants.luau create mode 100644 src/Components/ScrollFrame/ScrollBar.luau create mode 100644 src/Components/ScrollFrame/ScrollBarArrow.luau create mode 100644 src/Components/ScrollFrame/Types.luau create mode 100644 src/Components/ScrollFrame/init.luau create mode 100644 src/Components/Slider.luau create mode 100644 src/Components/Splitter.luau create mode 100644 src/Components/TabContainer.luau create mode 100644 src/Components/TextInput.luau delete mode 100644 src/Constants.lua create mode 100644 src/Constants.luau create mode 100644 src/Contexts/PluginContext.luau create mode 100644 src/Contexts/ThemeContext.luau delete mode 100644 src/Dropdown.story.lua delete mode 100644 src/Dropdown/DropdownItem.lua delete mode 100644 src/Dropdown/init.lua create mode 100644 src/Hooks/useFreshCallback.luau create mode 100644 src/Hooks/useMouseDrag.luau create mode 100644 src/Hooks/useMouseIcon.luau create mode 100644 src/Hooks/useTheme.luau delete mode 100644 src/Label.lua delete mode 100644 src/Label.story.lua delete mode 100644 src/MainButton.lua delete mode 100644 src/MainButton.story.lua delete mode 100644 src/PluginContext.lua delete mode 100644 src/PluginProvider.lua delete mode 100644 src/RadioButton.lua delete mode 100644 src/RadioButton.story.lua delete mode 100644 src/ScrollFrame.story.lua delete mode 100644 src/ScrollFrame/Constants.lua delete mode 100644 src/ScrollFrame/ScrollArrow.lua delete mode 100644 src/ScrollFrame/ScrollBarHandle.lua delete mode 100644 src/ScrollFrame/init.lua delete mode 100644 src/Slider.lua delete mode 100644 src/Slider.story.lua delete mode 100644 src/Splitter.lua delete mode 100644 src/Splitter.story.lua create mode 100644 src/Stories/Background.story.luau create mode 100644 src/Stories/Button.story.luau create mode 100644 src/Stories/Checkbox.story.luau create mode 100644 src/Stories/ColorPicker.story.luau create mode 100644 src/Stories/DropShadowFrame.story.luau create mode 100644 src/Stories/Dropdown.story.luau create mode 100644 src/Stories/Helpers/createStory.luau create mode 100644 src/Stories/Helpers/getStoryPlugin.luau create mode 100644 src/Stories/Label.story.luau create mode 100644 src/Stories/LoadingDots.story.luau create mode 100644 src/Stories/MainButton.story.luau create mode 100644 src/Stories/NumberSequencePicker.story.luau create mode 100644 src/Stories/NumericInput.story.luau create mode 100644 src/Stories/ProgressBar.story.luau create mode 100644 src/Stories/RadioButton.story.luau create mode 100644 src/Stories/ScrollFrame.story.luau create mode 100644 src/Stories/Slider.story.luau create mode 100644 src/Stories/Splitter.story.luau create mode 100644 src/Stories/TabContainer.story.luau create mode 100644 src/Stories/TextInput.story.luau delete mode 100644 src/TabContainer.story.lua delete mode 100644 src/TabContainer/TabButton.lua delete mode 100644 src/TabContainer/init.lua delete mode 100644 src/TextInput.lua delete mode 100644 src/TextInput.story.lua delete mode 100644 src/ThemeContext.lua delete mode 100644 src/Tooltip.lua delete mode 100644 src/Tooltip.story.lua delete mode 100644 src/VerticalCollapsibleSection.story.lua delete mode 100644 src/VerticalCollapsibleSection/CollapsibleSectionHeader.lua delete mode 100644 src/VerticalCollapsibleSection/init.lua delete mode 100644 src/VerticalExpandingList.lua delete mode 100644 src/VerticalExpandingList.story.lua delete mode 100644 src/Widget.lua delete mode 100644 src/Widget.story.lua create mode 100644 src/getTextSize.luau delete mode 100644 src/init.lua create mode 100644 src/init.luau delete mode 100644 src/joinDictionaries.lua delete mode 100644 src/useDragInput.lua delete mode 100644 src/usePlugin.lua delete mode 100644 src/useTheme.lua delete mode 100644 src/withTheme.lua create mode 100644 stylua.toml delete mode 100644 wally.lock delete mode 100644 wally.toml 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 0000000000000000000000000000000000000000..5d34add5ae0897851776e7d08f9df10d88c8fce0 GIT binary patch literal 451 zcmeAS@N?(olHy`uVBq!ia0vp^(?FPm2}o{Eezp}zF%}28J29*~C-V}>VM%xNb!1@J z*w6hZkrl}2EbxddW?zv|R_i$B;e$sjpdSdm2aK-hb0>p;Lkz9U`>&dHJ+J)PJ0ngy+>gTe~DWM4f%XYYa literal 0 HcmV?d00001 diff --git a/.moonwave/static/components/background/light.png b/.moonwave/static/components/background/light.png new file mode 100644 index 0000000000000000000000000000000000000000..da38f9276e712977ffbc765562b02161b228f6ed GIT binary patch literal 475 zcmeAS@N?(olHy`uVBq!ia0vp^(?FPm2}o{Eezp}zF%}28J29*~C-V}>VM%xNb!1@J z*w6hZkrl}2EbxddW?JI literal 0 HcmV?d00001 diff --git a/.moonwave/static/components/button/dark.png b/.moonwave/static/components/button/dark.png new file mode 100644 index 0000000000000000000000000000000000000000..63d1c91ce67bbe5cbadc29382342804b386526de GIT binary patch literal 2380 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 O-B(AbmEN(6_&)%Ed!Q%) literal 0 HcmV?d00001 diff --git a/.moonwave/static/components/dropdown/dark.png b/.moonwave/static/components/dropdown/dark.png new file mode 100644 index 0000000000000000000000000000000000000000..608cd359d475f7d6da3a55181a380d6480d62f80 GIT binary patch literal 6938 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 Pe4zWPx+;}QcH#dAmCN8k literal 0 HcmV?d00001 diff --git a/.moonwave/static/components/dropshadowframe/dark.png b/.moonwave/static/components/dropshadowframe/dark.png new file mode 100644 index 0000000000000000000000000000000000000000..b057ec290674e7b9438b83818f658ccaf2102768 GIT binary patch literal 1836 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 r0000000000000000002c6-564!BMXH@B3$?00000NkvXXu0mjfscu#A literal 0 HcmV?d00001 diff --git a/.moonwave/static/components/label/light.png b/.moonwave/static/components/label/light.png new file mode 100644 index 0000000000000000000000000000000000000000..10881e606f47c4cb861f9c3a26501d84840df094 GIT binary patch literal 1552 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 S0jI~m`hwGX8!ajX@Baf=mlV=WhXBC?Nl0=l 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 literal 0 HcmV?d00001 diff --git a/.moonwave/static/components/mainbutton/dark.png b/.moonwave/static/components/mainbutton/dark.png new file mode 100644 index 0000000000000000000000000000000000000000..7e62a0b91938e2d06ada40a56971455bfbd6d600 GIT binary patch literal 3646 zcmZ{n2{e@L`^PE8OF{^hBqd`Rgp_57DN76uCBj(7GGiHKYX+nAB4o=ljK(Bm#unLI zL~labGnVX?#tdVL8GFNT`oHh*{5t=0-gBP&`JU@O=enMA-}mSG-sefMg_sEo9uef> z;Sshl2Sd5q#vPRcd$@1Fo^v@|;SGkGnebHf9Gm3^d_EVgFY@qIr3(FY-^Y#r2r_pJ z=HWTm@@w$6ql)3&N>mH*Mf*Fh3lkzi5`(h*KVUmSA5Y73pO*@0F(;IH_lODXi;*ur zQFQqI<@fysgC&RSo@qhU5M`2edMOaIs(NUavaCe0bh}`d==1l@q7r;DKNIfaoq^xY z&#%xQIX`L&Z)o#eUtEa~$4$Ih@;STUy)?0aS*N|}o}sOuRJ6u}-4!)8uZ{{T#<$1^ z#O7)w4{SFp;hO$P8DXxpoP(zK@9q_dF~8^bKtdqym)M^W11C}Q!XY);^ucj&M54!( zDM}R3?0xFO6y75$@^O^en_D$HO?RxO>&NjI6e(wf7hClc#a+LAjo4he~1w3TF^l$`GmEiHP6$4GDdUgw{3rlc)py_mU{q>7D+W#|6JMvwY`m5jW-7)GNYcR$@mthGpd}B24@p zFBokJd$9wPLlo;kiOL^@Wlsh$;z0aO#N>kR;EgsNnc8hkV&ao2Z>sKY(ewc!C&Hq8qWx zQ~)}04RQoyA3T6H1rI0^uDLT+A0)+@9h-u9wNf?tlhGMNvJ@$AbA7|J(~Numc-Uoy z$tN#-owQ9xD@pzU{i@ST%Z1LaCPEz~3ghlU{-(At*C-l}*hh{WMNt%ze~$GYoy|qC zzwW_CwS0S58{S_7>)Sl2Tv_PQOzQ3V#2@=XC?ux>6#TilZ$}t zCpf)}I6uUMpSiM~m8s@+hJ0n~*hbE+gn>Rt!lFu+$|1zG|LB$^Er3oe@=uS2R6_av z^;f=`Ilg=Kz*$O)G5(`S0=BmL-W@@)>Bye^mH$wJ>3^JHwNKyI* zQa8?wN|~NSESqB&8okdhl+vzx7I+{$x5LLaMB9LmK3^5=j!ZS`$gURm#Jgx;i2YVL zVbiyd11_aIA?cy)_yqPS0-GMP?ur4W?)Xd-M{9Cg~>?S!Rw z&*0-qQThxGn5BsASm;}BD>+X{l;MGv!J+`s1L=GI`c0yblnQT9Z3STe!m{ym+bNtN zOFz3nYAb?tU$o@8nzt0@{#jXxy>fqBxS6{RT)j>)9_RAmVX#rV)IxTzoeRnSMpq#CSU^tqmE`16%Qhv9e_}0B zc67+n5!DA&nLuBd(gG!5LD`B;*3*^@TLoIv^tO_N;GI|`*GI{x$HG5N}B$Ai2XoqBe*WboUphne*lsWT{ z$TndQ&DTuT#xXa~<(3O4cOnn&)WmjI#40Po@8BlGv+ zY7SUq_3z6`Q4YG2(Ta6fJroooOl3Pa#7F83UN6o@d^AAefkX;@6FToxCMs2G}d~LVm1iNf{dpdcdW?fEH?F8|7?+6Q-@J`WulYai46A($?Z7yXFS8*w82Z|Gn-0 zK0_`J1nMp^E@sjF%iiBv`5!sjK`_B^vxq-;nrJ+}3eX&nR$s4J&wsJcD9Pj%bW~x6 zc}YQJeox|va1Itza#5W~sqqNgOYXgAV@PAc)pYE;0^hR50g~Ch)DX?rywtC6DfugC zJkVR$joZf3S(&}mz+dw|B)HD>{;J6Qu%ut5P8K~{K~HMCaI-aF9r1m*UZzkNuD@Os z*Gm-#cknytww9JB^^5-3?=253p#{Y`Q$G*zKga|RJiPnx23D#Cp4KF6`hPJ`UWU5+ zU>`8*Xq>hjV8jxAdSC6SD;QscVOvTaa_bu;cDHM^rH@xzNr)z#m2hmP1uf*4DIV4 zJK$%EL2hc#N@r!eBGvKCV8cry`4(Po1Fk}=3tO>r_A5B8FuO|aH|02qrJnv`CEjg5 zZC4&Pxbo6^Qzo*-H0%ZFoA@!3_2$RtewDLhXsZ$Pxmj2pWGvldcPmSUR#mk`Rt33b z4O;}jsl1UZ`C`voEZ37MhN1R!5=lOj*u!tzM*nJnnE~*T4P=J$s)ylbs+)MhE~u6@ zHH;Klk?DY>5yiJ*L+N)$Kalrsd^c3MYq&n6is@}@SRVnjzpt#7CdRWExfIcy6?iG| zM_F0$rAT16mRXTEJ}y@Pe=k=+l>D2w{llwdY#pCuto*TmrFlP=OY@1R--PgCLKi;*KR z1M;n#f!)V4>@dOFS#Vt63q8!#AcP-kWlE ztJFe6x=|=a_^?-jmd@ir9zGT5{KDt6FT(;YnTA;{i$>YVfQ;*1&%?ozL~}>dcu)UYcdFMMB+tWy-)g4ao=6+Iaagm;)fCxWsk%zdwnClvh%J#gL zecz=(-OA53${=*}qU)`8YB>zfOhx#**YdRXVcO1*)HqKxdhF5vGyIcQu8G!eufHQC zx_6pVpjtYEHqC6M#++i{==uw7o08dPWsgvDw{|MhQz(!#y=Bs_P^su;J)<{$?eBbf z6AM<;43oTKIC<{z3;;$^Yur3=w`)hDwslq}|sBoWB+>Fi5Gn`Egb^8H3wiUVW9W3mo&IP@VC-arX`06`OKmI%k zJw6}B+8tU=UElIN;v}sWq*?p6F)1JI$$6}rZa7uN>4<*4<7)QA?F|;UZPZRx+4zqB zz+LnWpTddQPd_D1SwoeyfytWXLk+`@cT#YvGn|a(r$~A3zB>}GW?9GKY66DITN|H> z-wtd+>lb`8?&)NN?IY#=FBziysY3*@5TX|>-VLFftvlGO!DG(DkiPcAsLKRc?g)G5 zWlnsV)qJ~QJu6qsLL>#DWQEwo=`7DuEe<+7Kf54u6kz{C1CJ@jG!4Y|d@w7uVySec zi%k?!ku2+HHC6#dc7Bfv^`)YaXa)(uMz1i|ZwPV3*dnX&&NRvRv)8YY3Cst9vk#R( z=}sYik+tZ)!|&Z%cn@C|DivK7Um7}!`i#nN=&c?+tUgBv*;4+J!5Fvy)>_d=1aIR) dV2O;~2+b|o@k`<>+o?{o|C@Rt6Ig>GhBsN721H`rm&*E3SjFI)&e205j-@KQ%AA5f>x)e8HXB z?vD~f>x-Mo;RT;ZcGZf)+IH8&diOVHJhnEYEuqZ?9Hpb95O2lD)n+9M{=661u0Y7c zefyCGCUWA1=+yi}Nu5)ACyw2!)KUHybe-16U2Tl?%ybP5psaON3-8=WNB||O%8(8S z0z>?NIBEKfJQ6?dJE-pWuBq7duJOruQ9ZDe$KxgU=#fFCrSF1PhllA{vx|-UvUdrd z9ixNKw)sn%O*Pb}SJB0N1E=KAwpila?`?njIv)KlNjpesfF$_G?1oR4eXw-o!WUYR z(YmQUr9njQJVasCRAOb0zz>L@Fb!%Hzm0KOrfry~uOWg=`i*x)ml(q1_b+d4Bs)LI zO_hV~1}~w;3;!<+Hmu^v+Kpu|?PdnwE%y5TxC z6YaSnL7|7}kM?ArPP}{^(#-C2sx}}bg33Ge(B(G5vXQYpd|m?BRUIG&xiPXzb?u8PaJ z%g#BPx-t*LP2kmA7FC10QAagbiL+~-&?37wtQLOGSv2XU&4b*oU~y7bzT;wnT}4HY z{xwP)ps`KKU!Po;?V>4&42Y&zR*~X6&BSL4EX+E19PY{?ZR~xVqWz2w$BOyo**O~x zADNE!KjAXiH`p1%MkDr!I{Td=Sye~Wc~Ai$Lb z`hSS(0-i0}&nd1d5W{MuSwAzL@XpWlS%%NC{Ws4q*5$-WxqHBZjxxo;6l;Ih$lD+o z5CxkSfd>-sL41e6diyB3groNrcW+hsvng8xFC*0S{pVoeG16-(Pu-&#q(G(W-TF$k zT3h)#sVgB5ad4g_ae9n;zB=)X*hDpXT})0kHO3XYkqOYsWR|1Jz@g zE_BVoyI*Oh6YxnN)ib^8pjUJ}qyasMHwB7gg*-u;2deW_UCj|-H9F7|p#%_>U%L(I z$Ss-7K1sK{1gvBq7`37i3fW-&kx=A<5QbDeWxZT3!~5eSQ0&jCb9)i2FYxfYa6IpCY})ZwGs+Y;Ml!toBYw9a%O&e@K^R42xVo8FzS7M@E!umVX_PqhMPD6Xnars? z01xN4@~cVo^C8~T;nt|qBu0~UldyqUb6Ml8#d_GG{ji|@pIm#CYacW5bj$pwX41p~ zc8x0tz(4`=Mx@QD2xH7L`E3ood zhft(}0tUMsYpyx4DwzJH^-&F6q!D9u3t?mXBRNL(k>omko|_#m(N(-0kf-s8EsZWQ zWjV<|W!yM2?=GY|k0e@FkT)Y8(sMb|rZUp8yc6Fbt`(ix*ODYK(rcc{VI7b~N8CHz za3{j938)g2RbjL-O24TwrZcrsE4eVN2ju}ExotYW1?JHlwam$En6T8T1xIge{A~dj zEbYT9?k}#u!=;KJ1L2x)7gquw2l#A1JQBhxvBsBEvieHtCL-+-^bUoCd?I=8<&alp zq0?Nuy?O09X~q|XSd+R!U^x&4iWd41sEzI5!?!IN9%_4yq z*GqoR_Zxt1)tV&VEm3hlsT3fh@)#kL7tCrNru_Bs2&lO;J{Pb53{eV~f-GN$rjqT1 zs)T;|dam-TEdkk3FmKqH?p+ zO>yDuA}QO1TGlu*`r|a>mb&zA1fkTcS>TYrHLIN|A>B>_el`R@hs+FG;l_mIcP!np7A+Nqiu3G5 zu1U*_2Xz-_!O6?sVAYdD^sOwxfqkSpX3J`1bv4ezMxeNcYDYj5@}e0eQ3qPyPPR&=(o*aUThfYAaZ?mVXh-Exz;O`khsHr=$Y z^#fl>U`#weS$|xNE-n>;&vE}Sc>4kZ_2neK4pnzhO`U0YD6vdQjZk96KJsE-R+skr zVxoU>3E|2Qx8#-cTCa4cb1^*KJH_~tq1=qh!$X^i5J~x+X4rFxgxG!n;VawtjA(6! zpT_;VIT2RPb0J5^rmb#W4gjCiYa`QHhHE4UKxOmuzc0LfI+NQgtLgHkGuJb(m|Czd zQ`H55qT^9!t0;TJ`9PURd`}c23|OMq0Y$XfoWPB6%lt%`w@O{|7WaEAF4IWWg(W)0 z#)%bUytQgsTNDGAjVpDQ{f$TO*IEDPDNpUwh)?ZPo^$>?&-&R1T%zIy)^$WFRi7++ z$3Sq8$KUVF>uLY5{`KV`mU?OA;_$_*JBzlT1AMb74>)8}^eR0rbj=pcN4WI(8a76@ z$-qY@ll-S_f|UZOaOM1O1`29~t=3)-*Zi%BG{CTe&+xJ23Lk-u^J)1#$ z?=S3DH~5S`$8Jo{k(tu5pVRXrV@x*4h+E@9NW0$Mgnizd?Q_Ep7h zyQ{Y<(h_S&>a(y)+HVxs>5sNk{SH*b0qKI}!Vb>qIXB+@v2d1u@_bxc>D#Z443x*a zdwY(Tw0YevFCU4OE0qfJp`Tr!rK%EvEjMgSLx$e8g=hI7x}?3KZy)p(C!|dtuS{*qFmB=UI*VE84j(HV=~Cj4#L75FJA80(tTe4S9KYP(X!jP z(%BRqHz7$T^?8-0l_d(^p(zQkEV-rdAcl4nkJiGrHOdpd4`?Eft{p;k^N!A=+Mw`~ zj2Iz&;3vLtG{4aaou=DF>Hw>%kL|@ zcH*rvcSWcT55TTD->TlLJWrC`MGe{W=#tHl>}9SEeR_rgx#g;Xy0N!9oL+r$g+m(> z0M}k`DIu1cA-w#MG56kSi}#n-g8XO+D&uvNpt4tqB-}0XRuv&sPun4&2%pmOcC_bfUvl!_EWK@>a0SrpE@+W*0P(ixz%ZM9hY5*~qo>;!^}w%m zS4fL7j;G>zIqif222(E$P!7aGEdyo$99^7qizVEGhznOV| z4Qn+?-*oP+I#qRK?|nK#MM)YJnGpHSn>VPkG7@TU-aw@SFHA&e;0o`FZw~MWl(U+& z*qh1;;sf9f>_<^W(Kl~u;!vJU;ehu@4l=sVZ{DEw{(V6W+W#-hS&wX40$f~c2qaD zJ$VF0C(6$-R|r9s_V1Nxw-dR&+p{zQcgx%3<>t%IJ>v0Y#vdgm zvQ&tHGP=RbuSkL9YS?OQz;B}e_?5L3Zf=GWs-=-nSzohVY6#K%;Icn;cIIH7 z6Z1VQJNv`ud8QXL=oHBKR9sxqn4Q!O1aZVBfO%_eN;q@qv-ERc=l^+;=)B$x1u7yx z@>~ZMz6E9C>k?v^tPKmhz6n-lzTf7g#-I1+f5#o(hmji@A(8j+;AKa}d~&wYD=sd+ z3@?fy0Ug2Gdg{WzvF>@E%IfjOfS!2W)~S#+y>FMY4vw~l@9_PL!j7Mqe?B!LWqoeC zCtw%bAv`MJO+;#>;wCJtOhI#joXFW%8RuWhPw6Ai!9M?3Ya|L2Hvae5f95XkStIj5 zd<^`qam`Oan=<@ezy-LEZobOcf62Ddkq9;(`TzKTuAWRrvgU_cL|LSp2_BRn>-H)^vQ4uA!8B@o;^#P@zBQ|2DZK zYn@#w)vLUw<6a(e)m_HU5x?6 zk$n7Q(+xuc>%R;WOhZ1_tZA2Pr4_3%?L0=Ks>2aE3v22UCy`BZjqQ&$udI_WLcG(D z2>;nKLOeO&W^K}@$uP1~e78+kDSeKftT9)1 z9^o{jW_n|n+IHKdPhD@Bq81n}qeKV7MT+3Wi-QejZtqIb9TC!I%i!>2104t9HFFR< zb*$z5yUc{NLlQeI4#UiiWA; zqGcHVYcRdA!Lq{2RwVJa-!>ayFv&q~gaepISArfCDUO;Ss4tFxDyEw4GNn?9{AHIrIHa9zm!so z)J2z~M2v`HFD1oLy`KYOfatzf0>oPS!lT^P4%^oo>X^GO6C2P#ceKP`|(Nv1R>DEo+%ur`@ z09XE%=I^@oeU&ic#Xxs9eOmzdX@_|O7TR1C>Epf#?8loE0hfIhKfeySLU7zUH2TyL zteU6T2o9bP{rHK73mJ*}slFu&1^aHDJRS(VH4-v9aa~dD1U+&+I#H?bhNh1edU{@B zw7aL^COiuB6KUu0@Gv$O%?h_Qs<5q%_}tR{c5$(wwzjsjv$LuSI4W;0F@JFNEDjZT zVP!^*rEqy?P*)jv?88L!8fcPJj{eCJCI4xjD)27-a9-$u(6wj6&iV$sF&rr_i#-{J zrMh7#Mb#&LyKi?q6Vl#MZqUr`(i(A`LQc9naR;7as?b8IlB>|#T8odzjF1RS!;Gi$ z{vwuJt;1x)2LdS<&r($Wvf{kcg%6tK%(ILGiwbQS8to+a zwI7BK@5Na*=_|AI>R9|m-CLR{l2RM-gM?V;(+xD_K8JqKKlB`o%jwyBF8rAp8S5dM znwngkoYnF9APlas-tLqQpKix8;OEB=D;t|D+FxxFMYHiS9zgIySx6C&H@zAYu{}Q8 zEZvNJFKRi?aO&=mZ>Xape2%cIb6#8KJXJlA7t%XBS@Q`c?UuzWPP9i4^ira!pLBcR zyo>-n?e=m@;hc-83vMY)^+?MvHhPc4Qu8+dXWMPC%a53{O`6}~36kVL3-|PEi$4)Jzv^CTj!ku6A=`qX6 zw|cK1E!+A)$1#V-JHzi1luVKn?zP`qgEDctv@z zq>r0vub{_tIz_eAnI+aW_QZ)CsCu6PFyC2@f;KEfjpww>AOGumVETn+gV9yug!Abz z|5;p{a&|mggO#!kCXsg>vEZBcA#le1$qlyR?Tef$R};#$xArzVj|$4qsh(N2urBh( zo2Ic&1c?ahqA^o;3AfqX4TJerHyK;S8SB>PL1sthuOWg0krWJi!@Ry=}Vq}S_bMT8CuX3Uq{D(gf*go z-ge;M8g>V~Cg0y5CHuFa(GpfZF>5&0T8_}=Wl`%mS@OQqbG=lu^A?2PEk6)^%-kV5 z{d!BfS1Ncn0kRxt5^_5lIH+0<{Tf`cfsp&Prb%h#?%;>y1|mAJ6u6PZBQ{~|*stKk z7~KAW4*^lbyeL`RyDI2P=`HRXF%J3c=yoHJdZ?{nu2wOdV^tTjomfPIaBbxkMZ?iQ zQ}%v>M|BId#xC?DqoZ4q5+mSIx(3U4CHihC%M^u$*N!67PCb zLX=vkOo_6Z8J@TO;VREtJElx=)-6YwlF{7PS2wJSxuZRhB$X^^FT=KuKkY7CH_A}g zqK(26?)Vl{4;Ud5{Kmzb@ILu&4CB1;v)oH!-3_n()?r}UeFm1&Srj#PFGrIezlm;S zA}C|PRw&Aj3wMEW=>~})DsfNIZzM`mjB-5Y^Jwl2u==IH(eGln$~mm>1+8NNkl^NqDiS_!Lqc+LhcplZ4rB z4Se>r#FPgj>JJg)r47&F5B)_@232gSZ^;NMd8>8~*G#toJLT;G(&dIWskvGeZud~m`dYX!pRQ19_W8=R^HQx?&~F&eokX1`Z9wx=@$ zrxZkuiyx*K9u}QkWN$gB61cnmbgE$mo#6fYX>{T67%z7W%lcYWOXMF=5y1k~4Gt}B&6gRJo+&r_zy|CW7mbc-x z=vMDIhTD`H(~d+HvKIB|VO~;Grw(im7F5|bT^uC%zsh(&!BjF}ba=Ai28^NlRwObDVFs9#?ad`!q?SaH?QPf;OmQMFsnioJtC zayrwE8$Z6Ed9ukC1|09^!@d~M){MfE$Hy0GWj3-~nKVIv!Ywd*!-au?e~%E?oiTy* zi{X9o=Wq9O#g#AZeD+#8lco~$3FHm#P$)b7YFM9fFVPwprfQpBbZU!5doF|@6nAs| z9W_mR3hv!sAw_fOUkW{xA)S+lCuiD~AidqcRl5e|N+~_x_xJK~? z_{JHIN;>DVcmzH!ghq?od-X^4XX&4)Sa4up##YAhV1S}3^L%%FDM!EhU1DR2Yg=24 z(1B9GygPb8qUgHRq4GlaNqdj8(wF~eoz_DQ$b|3q`rBl}1jg^{nD~;%ygexdKZLf5 z+YmIAxhi?K^l^SstmLORO_HZ`8s^GIXdR)TyMu^@N%H>+-N$FEUtB!$B)5CANfn*~ z6|~uD)u|wXMEM$7#G3#r29`y^y z<9lsC7pu{!|GVG#jP>nm#&mdtOl?`ot zJBnX$dEXf%qX$gJdY)}o3mKvd?O023Y~Z17?qH1qiQ8`0Nc>EHXdWE?`@T>+*I6wf z$atR0M&#<3B}ni3)a;U*`qnRywz^V@O^={zAGz6n@w=D9uOpN{z@jTEb&c!ViSEb= z4p|_6s#><4OntfZ7P{tXG(3Cb&p__wY}#!f3#@RksxcMG;a9YJr=@O3+3heEKaG_; zqz4*p9F(*@=7!MAJAALSUStZhgu#IM&2JcsHmTz2F{~pxIsyhYGbyT+F29ov!)BG) zJiQc$7E{^cc)cpc+}F;QEthx!#llONBm=&io_V~ zuComz!iMi1bmG>oF3zrS=;P`0+jK@u7}BEr#k~qu$pu~K>LUM`HQ6?0eBBt1suAf_ zGU%lExEE#mR3SnhT)dS)r*VQn%OE;X)Z(D*MRqLbEC6`_-EJ23&MuBjqoj--6k-Ci zji=QIeM7M4L?vqXDl<@l={S`Fr@*YOP5#&S+)#$eBOOQMfsMus;TPM4;$dLF5q5oc z7@5_y^=`H`BCIlbv8h^n!fzyDZ_1Y%!Tp@kkdKWZABAY(R%7wws9c~9t%Fq0?QIiH z5tP1Vi9Q_`2g(Qyi|Bx5D{(1PrdAwxpMUlmEA^56*IO=`XkYN<_pY6ArX?H<+K{TU zeZf6_U?I(YZ}htTbG{_fye){^U1sR}T{^!4zI>yd%(;HpOEJ!SwW~50((c+g^q?%O z;47O+AC(FU<#p4)s}rTPjJ07HlC?o=_#n7!J*^nj)HS(h9W;B@KPZ{9k>^f^wz3RV z`ppSsPyZ{bA15zZMFRBt~w0 zUoPdOmX#^3WLb^Oe5r&#lKdk zXvQIvXvqK;Cj43odfw-`i<>J4J>R7toqBJN8b5kf(;KC8zBcT&;;n>!2`@v2ig%UR z@C=N8#N=>XV=Lr8Ys1?ysC-J0iaQ_V#>kVH@fhc5ku$t_)6})GPsMIO*?E!J@(- zjKmv(*DnlWHc)iI=SUbaEQ}yOm|KA+0K-=K*1E{+)yHp|s9f zxXD<00rYF87DXFUe2z&FX1 z*g|nz@TEz36`K^&)u@izn&0Yej3kP4W7XtJjnV2FJ}cZnPurXCIc3LpOnsV|3CLHl zPfi~780==#KAKA(ZlKo9zhvV@1^176H@Jqx~Qm$MLGjb`c3xphBR;0dSsUG#qP zf7Ba@Z+tdnHFiGJuh1o9X&m$8fvS9ts%BG)li*LyZD?aN|HZ%cfW0E6kkoRN4=wsbu+b^$LJSCde{rc zDrc`d2D9kH=9Ke5m$%rjwOWNkulpqy#YTVh&cAh__;(>g(#*g{yI~jL;c%|HUn#1= zO2T$GjX;#_#bF%bS;gFBiePHEf==`!9nkdeG_#3S-i{@vG;1EtuRs-}8@790A60e626mlD{Mb+UBr$KAjSxc4 z2%QSk4fj<0m5dC=4k!b4bdEgv;xTEZnu95MnYb%RKYS;kDAtHT#B|Kddra$_V&=Zi z6~+2H*sIS!4)f;aNnIE?(z$ri>=I&K&I`J9*3W9w#~swd=wVo-y?O`l8m|+=R>W!; z(CYX?5qJ72Dg1U>JG$k;mR!6-#*(c1?I@K;Rl52&ajxB#EN`tQ@-oq+l~>x#3^ulE z&2$U*zXPxcIVYb0k4{}NWY1_sGX!2q1>)IczQe^5(No0Cxu+GA#LR^=5oRYwK11|D zwei@JRDtX2uDAt2q`n(h>hAIQo}aI_WI8u?s$5Jy{u*BzbnX7aoUhY)p=Ys2!EK5D zZD@j}_5)HLTbNRU=f3gZLYGvx_xGLy=HPfz|JLzl8Xi>-+)KEsu=2F4$Fl5vuCb77 zW0I{N-iSc5ksx;Gw%7Z2BzSa5=1nA}gKJzupI+7)$2!v$FT*02LdZZ)kg#G49`hCG>ReqOd z*~3)WN%U04OuDQ)leqjRTXKskbCM@`%jh7cw@BECfR{Y?*Bq^M;J^dYPVPm*NP(2b zYji_-`$mg*j+zv`=B)_|Jr8?Ej>jo6Xs#2{sM>pWk3ox^7`dWl*dz1YM7-#5^FJ#sK;YCJL9gnq-s>W<%FrPVum#yFC%4MaZ&D~ z8tlq&j<5(#kW?YC7=&v0z?9~Z+lk2#db&{>`1ORa50z}bKH=~2qJ+LR@xHF2+@~Q1`;IC&hpb+<@SGi zk_M6tl3+u|o0p8M&6UZr{m`_N`M>9WzaCG0w!hs39l5J0FZmxF9&&*V_DSOAizt+E=nnvJ7Nos$Z#r^RcJ=_DdsR6Ue#kti3ROgnyH(sm7 zlLH_{sg6a7%>=r|J%&4)u_R$4Bb{l1F+DDzP+6Y{=Iwz)k?#qlaDZ4mZ?&3{KlkD4 z9G9S9o+e{MU5(N&+%UDA+%XgNr^FKwRYofGD8O-{S%elj63H&&lk5LBJJsxPso=`r z-%foYySy#D;{~#ls9^kb?6j-;roF6nsgZvQj0Sb-YP;=I0hg{h|3w4pmP!nZ>T-xk0-XD|yh5yUXilegCi~ zfwW*aj=h8-RGO&SWu?A5DCc_sYK@0LJ933(Q?a?Ewz>Kb#QW#8{RvqO;rYZ~+w8m1 zWgqKPg9KxgHpD-cBNgy?IWZ>S)cEq@a-Fu)TCiMqCnZbH^%AY5B(5wH4my=e!d-!T zDy#K5?n(YkiF~T6`!Gt`@cGH1KQhmql)>lqBl2pqOJH{av|MUrjL>nk6fF8w?eQ}a zc z(x!8US`^p}03kIOZpDZgRe$=84}fQwT$-hatww7WMX%;(y-BOL6vD2&6l@R4?KmGP z=7?>>dEmDJC?cQ=&Cs3t>dC=&!>X==_&s4ushhHDsBhqHMuH1*`ND%mrstwe_G5ev`DDICaj(_@7|+v3&4B2 zjzIfd;yeG|$^;U}t)ly_Z+K>EckEnGRc4J(hE_br-s{uHzWyi+aF<+R-jlgwzc@pZ zMwQJQqsMS%s`)D9a_v@i&;5BKnZyU}y)1rQw5jCD`#A%dGM!R7yb}ykP+5=WeL&Rj zpS$#~qkr0z{RcJAH*q6TX|6x9Si%cbGQ>7fnCEVDEf(LmrY_sbPwtgsp(R3Ry567b zbN^Mh9dE!QJU+gCVYVTvV8Kd>>vLz`oiezj?N_z6?475O2ow{J z>uMX^Ur{;b5i)^851e?Z`8E7aej6Y>0_PokOvGXQ`Ke16KuIc&?-T@$1mQsC-ox6q zzk7f!hJ16UlXU@}0*N2Y0GS6l50H8M(7TmjGGaRGRDc6uZRU&BL<1a56ynI}*yLN? z+Tx=|!3kJ8%zvN>UIj^%z9J}H;WaXvOs!Wv@IK4aB&f;OD9fp$#v|9JNeHT>kONUN zkh4*!T?Bkgz#{-(bHsn7bl?ysIFti6%f<`xOTYZ+UVirnh#M{KRong-(Rq6wee|rj znOgQtrRpvmpF^EBC89C-nSewQi|FJ5@v755V0SGImvK1|#%3OYYD??6~a2p-62|(frBTe?) z&q)Ruer8Bpa@d*0Uwnd5_-6;erW}7#N~di@aFK& z$B#cxw*8v}!?6swAR_9JqctradttxX(P!tXw~T*}ev_FzsCrwgEVJRf+-J^h1KqJP zspG}gE)&mEv^q;pb7{4jwltPm!L8gSB5!Z8iSgmhUGDqJC6)Ubt`|p(E5HZ~>tmWi z*NqJP!jq8egz`=b+6MrNQF!n?UXB5l-+!uVteInJvQ?S?mumG6+gs9H^N|5nwr@(> zdU}y!faJjc^@n%SzXavZ&^&(*GWjuk5=%c~Z$9q{<7u5`a&o5^UgJ0lG;wBw%3Mq8 zUR72^fajrNBH2hSW;W;Rg3a$hU_+87?#4;=e>VIuy0>ox4sgRzL5zD(+etd5V zBPIZ8iWo!uvdsxN^YlFi{+l{1+$n=SzFPna8DOG0h^k`Fp)Vdxt@hXxDCi}zn}m)( zrj6sH6=*m?2g(krozbS!S&0-p^}{r-EIrWur@;%?^qfxBo8LUM%63SSJz{(UcN3vA zG*h6e;6jOi9EF%OKHjCUCp=aIAI8hfKr#5gY0+r5Q#a|_>dU0S`=uun4rQ&T&k&xs z{V4K0E7*}sHVkP;2_>52OOUxST4L^0Qxs+aw}E@lO;v;4;o zI$`0?>S`5ojOgfSS5!UyUY)s~s5!!o-e6fPw28njQ`NgM5upKt+s&oWj|M6)zgNJk zvaR^~V@b1@aGyRY+@;_iu46(VA#(|0t&kK|CWqhaM6sWhxEH~6`jO-s)2@q;dTOn# z0#`p`WH&%AQ|D|;m-1v$xojH|ISwVk_yAe&X2(UvaH&oDW(7BRQ} zeC9}$%AclN3IQ1y6O0c68%V!yTGbB5>fnA?BS%HtnDw(l(0j!beK>S;Z5GBQ3c zt5+?4SO&^g<}zc9<2#2@IGUY7SWoEGqbKmEnh(O8r@2mVN9lrpfPEm(Oz7{GSj8;> zwpv;m0b0Q=6_lKu{QUgv>zltD&|QU;2HHXpWn|3QNYL2*R~lexaXM{OvtY2+R_2ZC z+w8MN5Q?=FUt8*}?gv8jIb5m1Cf9UHklzAS&869a*-}x2A9Qv4mWC}`@n_2FFBm^yzXsi z8V#))Nvqt)iLzbKRItfJ#vh9{x}~3X21w59e;9Pj>#wLUjYk|ZW<=f2SEg#smR`Q- zco*0_Ewf&YYOoexZz3#duckWQRa|JF{vv%P4+UJkw^uiSM0uW;$Ts`jzN2V+&-@+C z>-YNd3?1;skA%pQn};XPv9E|}Z0$upm2L@L=Ix5XoE3Z-$hLLe-$9%~;4b?MoSSQ9 zO|=%plQmF!0Q>D$EDp<8H21SkvO_rjRRGopcu06vl)D1BjA{DWu2;e|XGs7PZ7~Gp zvhO~?l|yFB(=7l55BqML1T%Wdm(A;t%d#niQYOu%nky}lOT*J>v@|ER(<`jh6o#AFP^CF)jRLlq8Hx5@QHVpnByy+F_H4f+`#$?;zPl znR|$$u@7{>b`usrl-F&Ksqmbd&@k1i!vGt?eAi)-?aQDtUgx#QmGc2?Fa1Jn z5(}<==thX6rbMJTrp4Q8OXa$K1EqdLP#(wEE2Qx3cL2AnAqA#y(TcJx+}Gr)HVsCKrV5XaVcos z#}qaC;h7VDnfohiundso_F1wzkLADtHd{uUOqw+-WCe^LG3g#Y9yBKYM9`nxmpqNp zUD7=n(%Hs7Nss0q(7OL9t3&4gt z2QgK`lukD~(|`FRD1mIAq|PG6acLL|`w(}bIRq>C3F~9D*IYhsc zDK-2y>oDgG1fcY~OG$uk1XAo8hYpu&n#JG=dIaj;Bw>WSw|pomB)v_9o6aKBLAcxp zd$Lk3*bOXD@g75XQ5fo?sCh$Quy&I^XU+>IJ8m2LAvx34hN@eQdi5A## zQKKREILip4qGh`Y;mvV14|cMiFLzRZvC%vtUBk`pTVdaMkkvWM1P}uo0AhvLO(|ub znw5g4pjLpK>%I(5I&cM4TO_T+n`M6{IoK|x#dH`fI4*w@!_?-=5Dh}=&KLf%7aL9p zXutHmIslndRVjdIL={hY?O7?2BbW1;NTL{sOd)xtq9%Y(t}u zN^(*yPviCF59w2%Kwf(kb^;iIku*)?AfM* zXgtOOq%A82p^k$OndQ0`Eb&QYk?&c}8PdvZmWVcwMf%JGFTfP06PX8Cqe7GOafK&O zWQ(&Xqc4s;jVEFH)Lgg|c)HT>48xjAOQ70VsiuZylEWcncYBHWo{0O}j1!M`%A=A`xp=GRy<6 zleZb)km6j2@p>Y&<+y9MFJ4|+t>Tf7`+biRA2W%y#Yge% zz0t*jM4vBZCpR)6z)IX`iPLeTZ+7d{zLJu&Al_?L}&J$Nj?ff`R5={L+pBa1LV+GcIvZaRT$|LKhgAFEvm97cPx;j-!{k6WFC)SYgzl{ zkc4rx9%?-O(vr`DQ1kIa09BWu*56Q63=gPAdoDLV?fuYmk5Ys&NDgzr2}yzhp{hdu!h~| zZ(AV|Wp}iB&gENV&ch{D&#Ei63YPth4~FYXB>5Z|c9i7ECbqA`A+PYDXYmM?z)DGe z)wW1rfJFoEBeR&M`3vxRL;!4ubUBdLv1@?U45)jDtX1561~lO)YU`o^isT~QdGvv3 zu>6Mm&m|b}3;I$K?^FFPo3A5I-sdI)6a0|_`(=&z9$bux2nZ<5pHUMw&?*OlYOJ8B z%dcWitqDKumim~`s?zCF6CC6frwoKR4#MjLMYDM2PU)R#rA&o+WG#G6u?KW89zK*EUtMcqQ2Vrc5{Hd?_ozUO`>NZ9Ip5-W=ltBp~AhsL~-04p< z=-9jVmRvBcj3f^f>A(--Yb2z4#Yd{!X8?SD;o3#nm&SC%#7PCd71wZTCYSF9YE5Qp z$>QkRq!ZendTwG`N;>l*of0_pe$e;x6?G$B%BfdyvWcob!;rThMui({{IWxB) z({`Pw-=O&6wy~~cYuDaB$CO=kX9(Gf=zh0b6?JZrezO&eTHoB~)RzoAC?$&lkvMUb z>|nDL5iD5bVl+mtEWMp)vqE(~O|@)@`Z;Hg)EngfNY>0i2FR_^EE-B}tETLBOT*6s z%04f=`|Z&U0WUo9 zc*R#af&j4XoB@9iDpmZ))W2T7haI6L0hJTNK3X*S!|8_$Rbdnj)wf(_>hvK$Xtkc@ zg5&o_cjJnx#sFHXzyGuBLD^}EbUkF)J9gLq>RqERVWW&Sut{8g2ex}lNW3v0WqyZ7 z?}Vzr^7^&BGhj&+!;~H85)9xJVQR&8s}r`~R(j8$ElC<~NjDmlgG>;N{&F?|H=v2h z29PDkWxAPld!Q?KEK0(nERf~wq3v(5#o&)Uh78bS$Q1u3$W%!ZzK!)jg-BLMuxs)A zKA#enl*I6_ktn_-U4T!34L#~Zq3w!tZx}nYJnh&FIoVrP6@IcE^&(L zi=3EjKKc2x(qfJE9VqKEhYUH3yK)4kZseyNU`C2sLV$GzB;N@PP~GB`)&>ocZ~LSQKMrOBZ@c0X5Hq;v`kjT@cz z5ItJYD|ufltsT%UL+6%QLG^uJzSlzvl;M`}^z<~4r7o?k{NS`LV{B|JDJiL~ow@`V zI43@y0yip-RLHtUr${+xb-H``6%b1cxc{-a?jlXG;k6(%bNp88o3l7((e$uuxT5dw zeE-?~v63A~4HWvX0ZAGAHjfF=1wuL@k@Vki#a(DsP*BiUhSX6mR#x(N$Qv2CGi8oY z>1VLP`gBskbf-Nc45W?f57p>=0Lgq zUOC%^mw1sgb6inT@oWN+h@R6EPm&I(Z3m!o=jKzSobQ;DoHB12uRUy`+3IDOSTu(U zSvGC9HH`Xo91hhL`JQ^meZsH3-J1|<72&567yNS@M_=dJH5K=9^m1%05&!~3Zo0CvVuTcgP&|8p zp>54c2-||l0r3J&saC#2n|@CY$gJka)J~}%LKD0!>4*KxFOyk^vLv+ucsrhk@VZ)O zA2n@g6JCw`>O9!LZug33!vOLKF(cbpsrW@en6op+r0+5~*?h9)P& zF5w0%Dl04V^73kGrZZJCb%-P1-YCktv1la$p-1y#{`ug2V>QAXd8K5<#7V&gYrd^d zSg`Re3fZ@GLZ4kg@wcRjX!T~ZS^6tLodc(zJtTNV>-`2G7~N!(OB;MSnb}1Z2x%wl z9*Y7!XL511dN4RBzmC1$eKJPkz@2ZA!~)b}39Nx>3VrhZcUy}l5?Np9I?udv$GbkT zn$F^i^*FiN4)LEcv;yR7y3=noP;`Z9u1}aSy(tR~3-NcLzesRQ5#D$#fuooH9itG) zdr6aDMrS@?!NuFi^0tLqZns(M^84x1IxH{_wGSX5@-|o|mt;0jD7lQx63u@o=DS{N ztC+0w{szF{4jne%BXEn!!-wUpAHipBW1bukR~PR&}Mu(_7zj@3%>(XYXmo3j0D}fWEAV zux5?x{$(-D%UbtSq=;Oi%+rY$_4zfO>K!DvDy}-%4;d|<2(78)!2L^i`=cdMLb#~} zFePVq;v#H90mK#TuIn;ud*G$HWc2da$)ph%6o4!zGLv`RYO&^P4?l_(uP~Zs(-%7r zd~?bGZXQ1pnBiCATxBvl=qij;?E=kW+QC89aWzfPjkfk!{ zOC%e}yVdYy@A-|L_5smt-s(46k?4rPQrE^V-(6IdKyq54k|aE526ibqys}L92t{9# z(N5cm3YIkw1kR{*rxu5fA@+iO{xK3cjJ_la3m-O_WL4w*V_rlgfLCBi^fLk+|5w&x zg%jYjVbsy&MYQyZctJL@VZQ+p&x*Vq8H+Fq7VXbW5;?n(0~{Q_WycMXdo+Kw9s@Z8 zUJ;-X%_B`7)p$%p1!f#j*6){eT2wy(AkdJYmG+)k&w8Ne`-S(2VB`d7PqwJancTl< z%yFi_;c4K}D9~Qwz$pH2S8pJBmfPs zQ&$4gE7ibmN^L%DcQHl_Y=O1kozfQtO%}F!Dm<;{TW`zsZt>*~ zxtw&nhv-k)ItJC#M>MkgEc`Ziq9|f5@EFlhp7u93fHo|JRGn;E41jrRxT`?mjL9KH z*^`go>K8qECNLl$U~MO`~q2AAg3t>|U? zZUl0;^3wdCz^a9pC^+r(A7=;x&_P6QOK*)z5_Rv@R%{%4BcUZLC z*Uqg#=V;*9cb;tO-NdO95YHP!hwd3=8#e~_WLs~stbeRJ(f0@j073Pl1EUKr0Q&%R z(=6$Fb8X-L&J81dy)8{qKm64TZkz2Zn!mC}*?CP&Tm!l^SzuMdv-@`X z0U^`G&Kmd5_{8H~^Hm}lvaOU|82VqoR)U2gNIgRg?D8TfVcD;sU0hlI2}2$=1LL!e zs`|k-JYL&nG@$EsUq;2K+;`^|-qJUB|5^H{^x9S_Fk7%FrBBU-^B;n=)}sCAoOs}f zVLESS;~nXFc~ABbA+Pz$00Q`iP=x^rj zkd$=SO#&f~FH76b4(MUFv9&$<^XKoSSIOY%X6L=w=x7s=HV%p4uJz~7KoHlDopa_B z+%@_9`{yU?dM_)heV|RwO4Cld0Jg4h;mC2LDdy+TqM{<8$<2-m*+Yr>A36ym;eUzdc>3D(%~FC#}gQM|=o5tB=>0mxfc z?AGc(<2_hNO2TOq0i!TyPw(AWY;^~@i3*v`tZx}fEWe#zTLp?FQ&3-2NLeJBpEHN$ z*#5x*q}|)y*qBTk2TrhooaR{eWVw0Px*iQ}z$C?u)EQiZuYI!K9fW{J{G~|*x`{-! z%Z!DJN<4r1c&QQHz5D85mhIS40F1yC>n$DE@-sKc8$T#*+KSU7{0=7uah)e76nU8gH&?98aF4ELX zG#tiFFcH>&RS%zpljJT>W0vA`lHOwJOHt4!kWof(l0;Sjd9uOpG#8+V#)>eX&XdGH z$AALxrEW8{yy!0|Y+`TwLk5XMp?EP-@L0;r%FOa}_cz$WGKqOsk0EtOM@Q4y2Db0c z7Qq1miQm4hKp?}m(!jn5$CGM3Jhc8vZB7UtKM98>bzTe>2>l!u0M$zxs$(Y&j!@}G z#5LgVb9X3iKJf@7jrg&>J)8)%`|a}>*!s}X(S6|@q#DP>u%am-2pG;454*U$++r(u zgpL_tURYcVM>J$f?cLdS>qC9(dZq>56upEA7-0Qk84TXt-tKG7AtxR5s5a>i1R7m= zKeM_25T_Qyse{-Utw;)=$+<$y6nyg2$4|1H$b!twU?s9bMSEL^`>uE8w*noel?;F> zyB)t|FzMD7-+!^#PXZ9N*2uW}{^ygR88ywY9M^Re;|bxSJf11!H>Fc$>`_dW`wrKt9?i5wc+Y zLFWCk{4wv#>#++z$po6ax0e_HvdA9}U^}8&;6wi~#fDz~NEtGS>;1-nI;y`XB|mJC zxV)>r?=F(?IPi^QP6V(dZ^R-z2(&Rs|45YJn`JYG!f@h4p}||bbcBY&zs+5az1ys$hFm=XC8+S8_(ZODpf6P9ka8uuS=}={QKzgSp;X+NKv{6N2}!&bJPv|n>)G< zHUtFhKUhkr?G`2`Z@B(WZ;<$gGKGneQ5-e2W&c%)9p1;5&YiP8Kmz!F1aXM(|Mevc z|M`Ur|9^kaL`qA4|8S5+DUR1#9}kA(l2nj+vAmi{mXm;gl?cGj2bc0Hm;Z^Iwo*{S z$uA1e-nN(efsdryC(?7L8BHnoKMpAKOamau!x=HH1ai;r_j2~Q!!f?&Mj=@Z43hb0 zEy-vhBO^DdJt38X*X{;*=HuY8+)mqX2M*#yKj!$}ocg~$u08*8kHTf%EcL;Nkm!8c z0sVc|WxgO>`U^sn`d&@f*0MJ+;YRsCF0C=VLw4g>g%wDhE7R^g?zKoY3>|r&l%1{)UHi2yzwb)Y;3IcwBxaL?U|+V+gDN?O5sPyq=O=v_i>HA z?~(p&k>ZwhFO>VACV#;YoExc$p&^g;0E7!ly@_~)7%ArkLwEXQIlHAe6+iQRSWPyA z&Lz9nlZDQgYrspYzNx6FIGV5EAdFeYtbu`c>_z2ydfF2nL=4K5U`TcnP;&0a(e}Wj zxfb?Vj+5@n7V-vKkOxzM4qxSO{K;uJ;+_*b*9*p-6EKyM?(RH(6bw5~_rQh8c{2`D z#qPk&$P;)_+ZnWWa^G1a%mf%xYq2bq=cTbPqDAVppS#lP?tk*xIB4Ceo&x)UiUccS zoAG}Iz6e40U-`-#Z@dwcWh;Fd85#NE4}WM1zD#XwpF3ez$K;BEz~1T8r*GZ56=nFb z7@hw4&wrxui(mXAJ3ITE-~1*-9Vz zXR+JuJR^fY5X}oOyzs*hKScS03obZ*{CIP7^Cg#Da>pHaeDRB46bJ;U!M|{b&>&i^ z7QP#P01n2d>g(%2|NL{5`P%`6+Y~I&tvFR`on#=IHPpBow)%FD~yj-7xW#V(`GZSZpb`R9N1 z(MRwVT3TAt)6>!Uj~+dW-*Efwx35~Y3bz0*J(>~S3_lOYkJ$xPs;a6w_uO+4N$lFS zYs_BO1YoV;o^5Pw{PfdL*~&2o-+c4Uh=JG(3Mt1ZJ}@xwqaXbUH~x(`-iRB&si}!w z-FfEBnYd)Qvbnjr$BrFiFD2Yb{6Z8UXShKyax7lF_{fnXxL9L;q{lcP_(H&K1R}!h zKD@!czP|5#=Q~3~LpTA9&v1hg@?aO501ff0U;PRmJNlN_qu~J`L62w4Jv}|xWXk}p z@b#~M{f~eABkmBk#{Xdd8nzq_=;gRe(Tz}^GiT1;y?fnmH>B<1hadjQPksVjQ>)eN zk-@;v2)7-;xaR^lVd>JPY!AkrhPVJj%OC#m2i)YSiAPhgc!Nsz)xZP=K{=0Ce;Fmx0$RmiQcs>Zw zhihwV+uPgOP5*;G{`h0`H0(mN7Zw&mo=|36=BGdXDF%Qqed$Z6!EFLj`}^Pj4*wA! z{K6N$zbj4ej?5m0}h0F z#*7&lRM8@5pM5rlHSRMZ1HIr%X=#^Ve))+LC)gGYi0{EH{xuPRFM;0&e*wb_21bZA zY7kQ+e7pbt`*F!}ZMZ{&5PdrU|8rTma3TB=_EBcI1J6GDEW&*Bs*5hV$Ult|lcr$t zMwA#!v55`T zz}Ld{!<`e8?gYYW2mga(*ok?;~)PRjR1GXH*3zb3-Iqn!=gzMSg=l_ z|H05uj9BozFpA@XvWv;{i^L-OB^qI*0{ZMgeX-TLx&*i!Z+T<(FSZ z@PZo^0iFNBJRgsSJ$v@xhD106M+}>|X7IOBi7^CzIC>a+7{CSg-SiS}JAiS|1-%Wu zfi0uKckI}K-h)vDYJzb)PEEms_o5@-gkOd+90A()?c3SCp|pVa8+^g-fqH;)Jetv$ zg!QO!si>&Hz=^`nojWmBvA}y^_pv0SC z$9)Jk(e2p_Mpd%|+uJn95l6C+*O~D=jXT+PMx$wdZ;hy08F&^SmCr_S?Q&WTp#0#!s zNl6LoJNvhQx5cmvgEkZy=e1_d8u)SSH((b;P%f9F633w5$Eg{8Nm!2x9kp70?z!ip z?;)~=)}XEMd)N!@+l>CZ@L3Ee=!&QbxYrA!JpB9Sn{O^(z8sAc@L-4+95F;5ZEbCy z4>1=AL%U2SyY$ja@ey1g{0ko(ejIKAR318Xh&_@Y?~mlRqa%#$J{pcq&3X%{#0g{A zWG^!+-+udT#9uh)tFONL$}6wL`NJ1Q;KO{^Y5a4s4u@lKa1h?svSrKGuV3HN(t>8f zE&ASj?|DCqdjrlbqCEeD{cG5d_!2=%8+`>m7=rc28*g9}12QB7=Y{#8~+k4}UH$swdve@)*0rXwA#o*pz*9|w^fQY7}qvNlC{VRSs z`qipct0WT1O*h?y#(&|37tqczZ$?}xwAEQ>orRx$)m2wPa4|TIIXLFch+U8oP#fiyM!i1BcO@UP-q;DMuW}<(-3q7@=#mTLgXnd%ae$ z7n@eA6)nShTmA_c`gyEUeg66a!QUna$T#1-yF1qNVb?er_<7Op{ zB{?q;`C=0ZHAPGyUu<$iz8FCelN?epf*?{GQZa%cQX5)5WFn!ahzZHxjFc3M68^l{ z1gIE65JU{2h>{?P)P_`yAc)k4RE!{q)P_`yAc)k4RE!{qR3tc N002ovPDHLkV1heP%a#BD literal 0 HcmV?d00001 diff --git a/.moonwave/static/components/numbersequencepicker/light.png b/.moonwave/static/components/numbersequencepicker/light.png new file mode 100644 index 0000000000000000000000000000000000000000..6dad932b227c7c300371708083833ef8a2fbb0ff GIT binary patch literal 17853 zcmbrlWmFqs*EWi~6et7>Qrz8(yBBvTP~6?!o#O6VT#HjAP+W@?cPLie;Y^?BUFUnh zb$*^7$s}2md*fuDLHv(dwU6FieE;s)rCL6+7s95+dw6swz&b1inpYZ$Kzm_FXTu3 zR1Le=+}zw^v-pkFw1JVQh4frO$DaRI2XVBKZ0ug^$wJWUv@Jg)eXqrZVwJ|oJ zC17F8h6Xh)D4K74Z!Ep{?bXMum@WpyqA7Qqn3eS{<)j2Y+hrWjvTq;w=Umnk@&N=t z{KAe{z2W!o-U{_L`RFW#@`6P zGKmLHB~cRLjis52e~RKGIioQ&i~sN1O2Mp(n_c)jF8|l^m^5U>L&+AyFN4IGcQ|$I z!#)3Gj!q+*K@F^b!T0Y;41M3~%AoL^&56Kyhp{u#wBv6QAMq5Wm1+8)%MKaHzN>8W zd%WIibVz-9epXde`<1Z8o-U}b8KKs$fWzB4bx)iFa)DFMx^jf+H~#454s80uQTT%1 zB?n~6IQ?@Se@DRa{>P{Hl_}$!XMLW+)h6TUN_de>N81~Z(K+;NOk&Lq^!8{wl8%Ps zSRbt>O_3u}sfsEz_4(_)WI$WzG~4ngF${wLU07TAt7j@7AW8}Aur_si>M-OsBgeiW zSCDM!hjQKJG1hf7>6F-?byz-)gX1z->!Q_)SXYlpAom*890qI`??`vwa?>uNv%;F( zZwOAD%CVSK$Q)1})gIDVxU?py4_;I|r~h*wYsg44+HYh|YKe{QUUTL;fr)V_SX3?B za_!XQSG&tj!feB9FN1LZ#HwAj@7Z&RO%@K%kWKv)Zn+XhMYnvOH5SzG)vz$O{6R&V zp-h@K8#@!k0mZ`Dp#DWYis0kl;0w(qkWDFNy2`7+NkyX-I_+WsbCDlW(nqC4wsk;1 ziTjfGSHA}S{8rTbQo=g3ZdA$k`DYA4qLY~%yq_Gravt~&$7OX!S^Y1)%SlWNzufU) z!$&qY1y`H$`6bF}qgZ6Daui9$5Y++Jb=U)lE(o2!K_Dhi2IH(~KI3x|h$GOEy*4u3 zOaW;Rj$p^2o@JqfH;d*2zji7sZh<~UXH>MeQ+q7VmspR!|LQABVe&6L$L%qLvead=88^E4b?jJpMw}#4P}B}&CZX$ zreJaOLF2-5p_^Ew;r~*+KKUuOkkj@2yFYT;el#45??G~GU9Z}<8DUzK>esJd44TzH zCnqO={?u-FHZw5DZ5%)%zS@c)#e4t0AU1@Eh{&v)@Biyh68FFTam%hdN*eo;OE(1m zL5P)K48mv|SdHt#?-AtZ=l}4bNIHS!>({S%$&S+T z!~|T{>M}@s5i8ABA)U?D7F}t@UPTUxhaMCydxS+WR&7I-Fo04_OvQaB=jLwh>hk?F zUrBqu+(g63XuJFKt8;ouYwK;YvXIw)rUeWP%w}aDNF17!RKWeDL^e4k1m9s>Lg+;M!l0*lf8Eu87&8Qo6fsCzbGJ~hZ!`x zwmS)?3x0Zi>Xg68XVe?G_cUTzfeNOQRZv)Mwn}EKGwOXEO=W1b{d_Q)Tdth1$sN1Y z=42`+7To9r_OO6c0Gu>&Q>uR1la{T0s>=H4hkt^lALiuzlO*RRz+SOA(qxnSQqqz4 zI##zp_5QfE?0A7v_r-sHk(1Ue+w-y?+9RWLD@IaUdH|~H+xYl+PEL-H-=n&wX02|^ zx3V%Rl26*&v>YZ-&Io(o>guL`{*;lIkBN>>x{2jUG@$aI1QmDAyR!N$ue6XQo`5Jj zT5-xzC5u&u+mf;n9x;X7G){Ow`R%DQtlbE&%)5( zE?eXa-ps3k`KKu!m4#ofd!EivQH!0D_xAQW-B0CYWsl}7=bEkN!qEtd-eAfe=&FjH za7{a7)^k$`adSpu2r6ve3vfXG+H;O@(frg8_C4-ti%**$tHNsk#hLFZftVGt`?>Xg zvnUej{oH_L=c(qEi4nR1E4g}?aGH6a?ZwjBU@YcL1qJf)oGjYWxA}@4Y+P6!4Nm2u zTtSKQRylN27S(oZLfNL>0BAdzuKwT2=BdA8=HN}Nf@!O zFUHCT&lDPLZ5gy-5Ww@`Hg1Z~$#}Mt%z6>duH}}jq=k^2G>n41jBu&7$y~tq)bpW1YeX^!-vr9Hu3$$AB3T z&HY2@x@|(2K-LsD?%r3`p)FSDVy6T;h(7wghRt|(OeN(}+1*}sxNfM-qc;_GRF z2Tg4GI#VSMDNWSfHe^i@QY9{FiHK#SAX~fA!GLIqxP2p=Bof$e8S3d}i5knS1JQ*! zO5HB^O=69!)VG{4V52nlxcy{}hF_)a)qVdMeb12XCF9wLDqs07UGC6^Z-Rww z_vorNM$6Kt+tcmS{%@{)PU6Rwhb&K)Aj8B!|1~^0BWFfsw383v*76=%u;lXexB(w! z+gtC3BmHyrQqtOw)tfQ2Y@$tFFoS?+TE+xN?TlcJgWP0(CKB)Yxp#?oEjSc<=K)fn zpKydTLTdOyQVnsxK;KV5rM}qjPadAv)|VgJnly$10gASTOJ8O0GNQe`Esoa>w{IP@ zO4->Hug4_%3yc7pdFlL?PMzpqlP%{#t-sN|yuI*_2)oSRdb--U&ty#D4~^;fsHtQJ ziFErvX$y`KK&*e@c1@ow*T+9w(dGTBjD7n+=@j5lDg{PbCK`aO9Q;h3h6%M*@Zjfd zDf&hAv&7R(GYXZ;wJOjv_1>dT@UwGU1`d_|sTBP}WexlV%yiuqWE)dBYJ(V0Ew~DE zwKCuxfk^5-rY>HM@oKDMfmt0U&Wd?-LeikxaI>M>c?5;rhIv3dk`;}Vw&!pAptcVv zl%ND`&_= zvVs_K3XI(1Osu**c?`SQ)w{T3gK0&5Nq7FIZ9_6}!YJlt=47qgJVcXo&y(wTqCA ztILe~Cz;{>GJZ;x?*7run=GV(&JbH>sa$sZ1&)m)TI^?!Xtck^5sq=2MeLlr6BtUY zl}WtIH;H785xlb^HuT>}Z_~ORGa{_|mDSWG^8B4lk82Y;GA!P~+OWgr6a{Uq`ba!M zc8K%Zs)k4pww&!?s*o$70Fkt4OaYt;B$wMm@20~S*fLgkf1cd> zcF(NZPEYte{kuHIGJM7K!SyN<;{ymGZ$FVabq?^p_(8EID;zr>ZX>v z#Nv;FukOPs@-Q!AY9hUVHv%vU-}?`@mSOMr==Vq!n7wt0q~E_ZY`2T{P=5Ju63@{Z zMl{**k)oLUE|`i&xuH7CFWNJ=c)-M*&(8-1mw!vClEcjpn_M z?B-pXqOgRUTSH&wU^UysI6fS?9dGr`&$km;#mi=JA|gM1%p^%D(Tv3DediEgLN>46 zPnOGyZZ4G+%D16>0$i%`W_x1$aBX`<{bI@Za#9^e;b3AptmB{gV<*-py6^tA*~+}H zf6cWjXhlWt67p3rbw`sS7toM7qGS=J*YZb~>>R#?Bph8omiOl46MQ&3q8oB6f0FoK z=H)$HVLwL_&FaG=h;nZktSy+A)jtS7kPm%8c$POhxJ?2phEV5q4mSC0K}g&w^oxhx zj9BFn%>v~}5>lyX3io*PLx}Brx~F*o*lDj+HLKggTcQb) z%Ko`}aSaLoLW=-Kia?OBQfKJLTmOx27Fp7mB@%?CSP>!*bB^XgeYmJtYq$=PB{O8x z+5V+wBFAes+U46%rOCEI){x?l>0v3KEbNtnpz1Fuxja7^hJA5lMkpAnRGoykoX$id zL_0bDixaCDE-8qYvkk1cvMEcRyd9RPNe?0>4RMy$aVm6{EimHKNO^k{in}y=V_RzU zhxQDXEloihAl=DTUzn>(M|81rJgKHVpGa}~Pt}Fd%1&>!pC-GNMS0SWbB#bssnE%1 zhC(9TTiEA+o=__06Y$cSn)+yuiraf;L#Xu)u>QDHC9#|}*Uc~iT~&nGPst_hx>$aC z2#_YOxNGj4J%^2aVktS4t!WZgYg<|E!GLp`$gONTuYg-amChH^17K`$GgZZh(-qY^ z#tLq*d`*@|I$tSq3^AVTj3nnM&DvlmmLxG%yIIwSuu8Q;NSbJ35W+AItLH1~$TcVTOFYW1`ZvWgFTC#)vsqx+NQSBRUl(FA-4yks z>R|3o(UxnOq2+$iBF?qTHpK?{ZxND*hSn^GN3T%KpYgyyBFhwo93GWB1l6`te;rDB z@q6D5x2-WlRh6OTLKfjHOTpE3f*v7Et!JBWwSl^Hm=)o2l%C+I41*g2a&L{4l0iQ& zThJ*4-<=k>v*i(f&QILp2~e|`5&kJko;(U~G*qB}BjxpY`zX8wyA5ApbP!D&7Elr9>=SIS0fSY&qg$gukL{8OQ1R)}esVdD

NA#WFyxmcu%bbV=zq1vEVnq8XS^4W@WEhK`6zE0T*YTNO-O~&)p$MliM!BR& zwZvcoZ_`qsO_jJ_jEN=>UNe*iR$*6?Xjj+7W0A={aGlX!fS9CZ!25iWKKAn=w=-S$ zyxeFSN6!VBgF9j)z_u`h#7eK9_&6;-mvGu10}RWf?zzzxJis&W>jwFHI19EokYeI` zZl`VQWJLW@vR*aX-rkOP%#?;BCt>1kKSjSX{Ptiu!=okJPw$d7Kaf*stIskP#$~eB z-gR55z)zO3Q>2}hDT)KA0a8$j z*4BcJ0wY!3qZ;|WJ9x??*c%$T{%U-;FO)87I@Qr8cnn+mrlyxgF!8ZAh0V0bA1w8( zrRzcQIi;}50!GS(%(%^%F|OQ;(GV;(kYFd|+}BP#R?h2D)T(vohs0}AmtVv-Fg7%O z-)H_WsqKpIvTmTsCaGHXrMfpthZt?k^xHSkXyoXU#~;jC4&{MU2~E-H!-WWq6yCN$ zRkNa4Y5wk+`ztH_bMYtmB5fTE$tKB$kHX^gT45@ALWkaYLgQ*)-na!uK24U)zLsY; z;&iOdl}3ToI8)`2a4sd&BbNdnTcvV^WpTg3?U@Pc|3tXE%&O}0_NrJ5u6lD+rM}~a z_cFT4tTFpZqY}2z-E|f!{js76#Pea;vwM$lXCp^O|Dh?xy<SDoA^4&d3-0z=VH#9aKp zHIXZh3oBupCuL|M_Gp;UDKdO>+D?b+PBfrznc*_a3h=Vlf-7{Nq4?xFuW>$JLVgn2 zJ`_6r7b9op$0Q}r{`Q`6fsxi2Bmfe-@<3B8x&wm-IG1>_h=l&5l%5IogD4!+f)L6-1H)!;VF z{j%_tcS4@Vb-5oBPS)6NA*nWMmSCL+C9x~(G~~qk0K)>6&p-`HSATZe@&^>?-`AIe zZ*HV^&;O1k@^y^n==8gp>Cx*!15~mfn*(`mIY=i=x zkj_Zbvbho|)7Dx3oW98mAvGBT zqcvDz4G|ovw<);Ma%%~tUeNmjw_M7Ydn=b={)gi{LJ{#l!kxDHiC(bNES_Jdh>Jlg z2RFDY7Ey>#OITvT*K$d$ycD{I&6djrM8zDWZOLic;qsm26Q@c92aqg+XaDJ^LiRtH z=sPi1XGfA0M{cBXo7tELs>+QvV3g#p47hGbAB65_d@GyNb%RV-L?M@iQwl2Xat;?B z%hDv>(EvZ^%>DiVFItV?rAC`3$mkOh3^f|K=no%P)0L(o=|uMwF>uSG9lX? zUlxW>Vk_RCH1-o!&oc&$ou;L@lf5b4ZcPVf!er%6Ih7R!7z{ zOD-XU=ZFkk+#?kwBJ7R)E(I|pSH%Li=L)8WS4`ua#bi@TWe;yGVVcjs#9a0u1xAiN z)VAflE>{ZP&HN(5T>~E$#(#0CPBwG|QI2oR-{+mA#V^C0aW7JdR1+%upP5D+)vWBQ z@0oFRe&PX|x=leoZN2$zToTL8GNN(!A9f3+pHzp`DYQnd28ZU6W|~4`*2tNb*`{j* znbt&WL~Nx%(j$kr z`;|MqoJjEyjTz}aE?iTNmA zG_o7|5afC=!C`WVF`L_T$4_P4=1y}t0&j}k1ad8W!&gvR-gmwDDH=_rzx|&aGKVeQ zNW6|fv5~9(Daeo0tiQ9k<#%v;yhC^>LC*sCA-+d61!1&Sl{=1&ErRqxu-kSxmE|OL=%lI_kMU5`$A2Ye#H4*HDPg-F#MXzm^1J zJ$xB-MLc{ZdCR+7^;#}$k}0S#jBdr#-4#g*d9I*z=$>Z-X~Vf?cqXcvnZ32XB+-d{XH zhWAlYRs9p(Ls>QbgqJ6eu8ohQF;AD^O9C_RQvV%(yor&vO9ulC1l2iC*G1$LZq<}w%nQS@D7uP5m9fDrOq&NNEGKE7y3hHi7y8il0T{}S}n?-3A9h(>##uZ;I5B(mBNk!0r1X9g6l z0-tik+nYnbjY388Oir^2m|JD>(bNRzIV&N57qlPqD@X>97tUv-%^=Y2aq_(FXL9z; z!yR0iOxML(&7b?Y6*cskc6N$%iG=u@K|OPChFR$(kAm=FB zR%nZfi76<==I3wZ@VOwM6PC>X(fRM(fssYKUIY&xNA8Bjhayp3g&0m>k6osJe(@-A zlaPWpEVNR1?;7ge+!f^d$E3M%2e}S*-JHVQ^JE47^|1s=oXJF0Vf){MTjw}` zXnigjZEZxhEF9wr=;o7dPnExr{>V?LM@c!)MP}zrn%tD1*lg6bwCc1QJ_ra1P*9le zji$P}xs757F8x|ss<6#y@Vqc7{eoFpSt&Q7)HSE?S8OjGer6KYXf?B%bk%{C?UtE3 zTE;ijIte;zEjf<1PEm9t77#dP(wQrW8JRUu6%>FqbL&7}aeZ@%LKzx=I-mxYJ4mjB z;Qk>Iu8z^I(<7{7sMYvmSG0HH$kZd>?PU0arsJQbdSr>8H=6f%8J=>Z_{_}AU}0ei zdS6Gz#&XJzwOGv|2xT?)X~Q^?ek|)-XzCwhndO%vDz_mN^Xf<>Nt=i*@w)GV?rbzo z*(VisG5FaMPmJ)YW!R$qb-`>GZ?t4^jUunLIj3fTGF8RhORR#T-FGO=5}6XcLNcYH zp#kWvtCi1kGzt3LhK7a35m$C$!1ki`Xp4wI2?@EMEXgKQk9ZG>*pkO3N*r3m0ZqPu z{9+O3$Z)imC|^R(1c$4;G9tulR5|dn==EG;uZaFbM7~V$M#qhmzLk~)GV&8;l-N&! zqC?4_mJ>L#HoFSsOm%#0<9cT}H95c_TU$XRbJ0fQV?}Di!LL@km3Ehd$$a5}^)9b> z0us5S@y9=~EnD`qHs0Wv2TEA0hCeo%95YBWY=(*U#~x3NIgcK(^gRE$Kaej89r1o4 zpU_V`TN20*=yg3(-SLju(>P}d61W{PyZ9YlhkldIW5+Etr29~J{<2fWVDiY^ux~lK zdhNzAG;?YB)>J*hHkkrq@d7bxTjlaz{qnI9a(wn_JLzu(f6}%J8yp-|%uZ>BJl-7F z>9>CvqqbdbV3DK-VmFe7uoh%%;M;KKESIHfmfOfb3cKEZAmu)8Zdl!>`JE<4h}}2&EanRp z{Qhs$2!<94@$%W9LNiR@Cit#}94?&w8|xlzS65Dj{;5%5Rx>hkG*_-{X14cDqsrRa zS}9)${eKT>8ZAWg@DvMQ%^W1C@X9A(vuCZ84&pd$r@}B7KRdJ@u728@P##5FW@92t zd`{?V9vylsT_O<*zh~>w7RGudCoFe9WY$%mXd$N*L24I>36K4|BeMbN`HJ_cVM^%f zq_R9O*#Go*3j-BZsamxd_NSJDpP6Krf${d8@XYG-eI%p{|GKJBr7K+|Va2FJ2k85j*?P45 z2aTCskHdY>F(>vK)`l%C9qPhQ@OGnmYP3q6VbWYr`IsS&eLHb_-4n)7kVQqdE-b>4y+y@6JDDT+}&Qvj88Sp)WR6dIvmvOW64}j<^yT2z+C7Z z=m(8ccq*c(>GE!(8;9|`+#@l#i+`qtioYM4pUv7$eIkP2-wm^w|9bSYtox>$f0!mT zqZ}wOzRW=%(lGg8l4T6Do-FCO65!Z-@A6h8Z5K!1rYW91**v1 zZKgP@roDRIyK&7_5w9&@zGwu$R=&SjeEa^_!2%M1fkjjXBvI6$YK1p$$tP|T-B7=f zB>cKC3OSHtsXpc52Be{FnZdzsq7Q3vW#vhF5A&yRg`VxD{UUAsttx2S;u zm`r=&5tf#IW5Qf!bPZ5i&0M78eTE~ZFZuSYMgbi znMDr2AR;yQ$k;4bTunlDmEL&7Z2L2FH}fR+Lce2r^i~f5D(P#ej2Cgq6!&!IOsA~w zEPbFSb4;wKd5QtuBsgk}gC~j)vJ+XaPTEY%5da5kF%AzuJ!nX{A?u8QsD$o+22sU& zI~DfvP_=H}@fB`BoigGD9PNK_9m7`h%Im>@;e-ubszz?Jl#>^iNz7l6W zN$wd?89|>deSX2-R_b9qa(2u_v4Nb(%rrFUR+{L(143gCMf->Pn!TB%dx8yIaiQM< z398nNsw&D9^dI<_wDlGv0zn9M!^+brJPVDcZFSbfh`S#xjb*eQ1yCq6(@>sFFq}I+ zk=ZtKZxRLVRsdrfO?7$ktF7S{d}P=Z@Ua-flcraYKzO9T2VM;|1bd76E$z=r^C2xL zx?u4zF!J&duDded=~89T{)7jdetv_rpum3r4Xw*fyYK2}^#y$UuWPE?#V-;R|F%9i zIt8Edoy6D*c_pkNC32b9V7d8cl&^~JhPiplp`}^P8(z5UFng7RY>n!UW^J0j#3Hs(biCZv}JY6JV+Z=WHu~pmwU$%h^Ee3N@Qd znMY!BS*UTdzwDu-ltBi1GVK1M$})p+m?pF?CcU!AQ4KS=kgKf7NDGSts*+hn&a^F1 zuA|qE;`>G>s>*I8wEK0LazIVc{-O7|n?er6$Z{^E`tOVk$dOT-lD?&@DMfg^Gri8t z(#$yjd)4$6>_W|=`AekTS6H;&H>Peod(Nbzluu|)*|x|YKrwOcJ~22Ji%pzeRk1{+ zf~+2WZ5%)ci;1B($Bf~6nU5`8P#(6QK`Nib-`er+jgWRJ1g1NV6&NC}Of*G1PezIe zmM0<>-;NvauLv4UTQ$N)tBdrP+RlVH_L`ppL23F@%uMzO`^#*@mnL+3*IoE*LOd+6 zz>`Mls<%I7lv(1@f*d#OhUp};J`a%@tMxzdNQtyRdX#MvPFBiCvfx?7&|wWtjLEEK zfH>fO&{{IW#@uj^h1V~a7lEh&Xl<0Ut^c^UzXd}%?b9=%yztQ8oL}DZS@_R&=?oSoDVLm z2V~pX1_@LKnS+Vu-zb$C++eqt%c7 zZygBq$8KyoqmPV+v&y#HvMx-;`P$-Z#0L-spx6dom}kb-Z)7W~h1+FNpB2cOg8=t?Xqhd}i%p^!>8}GyV}~5cT-H{XHcqrnYD5w>wmWA+8QPP1 zvg`fPaKw4`~kq3joe9oOQ`i-=wzn>)_$SJ90H$ z1QS%*e$50NOlbzLq5a*IIlJe<(=ASSS@_|}$p$spb@O3Qt!5otSq<`Ol5hg`bk&%nWus+j$(PuBSD(CLe3Odd?A6ZT{bT@E7|sj=$ZU1-wS;2PUDCQl3q0k|Ks z=Z`t^JGu|(UrzPu01z7IZ$tCp>OIIeqQf%mB~69R6m5Wi|s4f z7&diz4#{jP)wqziTgjn-!7ngFMWKYYC1M(@6iyDPc$cjUHu-zYg?MnN;H*{`zj`Ix z{u|TFJ8bUOPOJM$i~|8E{raFsh=-Z`_(zsBpe>JyuIb}arSljC>p82BdQ3xIk!^F@ zqix6~7!Vrj#_qk`S(Hb4a+bFpwI>QkH5oM!@z>zaBN8uXPlg}8X-|f*=xcfFHqnQYrQ%XjDMx{9G-dW0d;&cNbPXQ~Y zo~25dt@A~R{`DBpM(J0O`3K`j=Bf8Hktk`b;TOodl7=T@QMA>#$^EH{_1i`FyN%j{ zv>e}#)J+azj*$1qWl-h6696#3t8Z|jD0!R*W&S4U`K*gsPqJfokXd?5$%H6lo-)>F zURg#(GL~wO2=7ukWn1zI2^3`U%@Nkvx@k(5w8X@BI5;~82Rs&&Iiev5bS+|>+}uU4 ztL-kLqM{z_UKXyL6J8zW6It8aCSE^+;ZeWztFCO$qs0Rf-Y zHYcDW?({hKe|kWj%o7}8 zh2q0m5i36;8SMm-xx*x5e9~8RKo$$C8ToO!AL9sAlTe|o$b)$b-xuM zE-rpi3Dkc*eosZtYaMPCp-*MR<K&oLIDU9Fh>SFK9n3<~q}>VRg;IS`A` z7v6mj7G@DLFW(pGwd0tR7k_4#%&|PiDi?9criDS5=f%3ux#UCVc@GhX`M4s_8l?v` z((3OP8!Rmhur+_!Rf6yS$7xo|)RpKj zm9Xa3r5gL@5n-9Ry1D`-pu4-fxw*NL5>N{I(K9e;)#z%dsi~=|UfwJk4RCSDECB^0 z022Lke9Xbl{_appQgXx_R#o~G@&G5{p3191OC3&+4U9j{1@*aGqe2bnK*Lv-SzwhP z4;X5`{W(`JgUo;vu{opRQLhFt>l_x7`n~=y;pjwXZ95nN0cKxqcx{}VoFEWL1SaW_ z2lQYbEFwnePOI<3C6iw3=H}+-I_;MB>&LNm@<+C+s8UMf00Q#7le&$X12JTDyIca< zxj1&TIT%a6a~1AGOMS@=vb~qnc@$ka=U0)eo>38fEIu2xelOiG;Z}Uj#JB=E4E(LQ z#W%`CF5hJPUzv0nyCi#+r>5YGy5cDr3-)^Jf3VgddAt<-+R?V*%?Z38Xq>vJOQ}!d zCscO|I0xb_r52FyvH}iw$2Wpc$as>5nRpWbb_#!q$%&iUx1JeHqgQLQdlCHcD6C^=hyzm3f z+P*6whU!8ts;w|tWFJT2Zig?4tS4chh3`1>_yf&fe0fM^sP6z724DQ@T8i=w>h%fs{}6qkJUtzaK6fn;DnpNR5MwDJxpcz^!1}HF z?n_wQd$iT(8L)4MlXtxcD1mf8{A4QD1lcrWA^3a`QkynMDCw*Z>DMNV?{(@hVQx*F zHRhm@)Us1ka_Jc$&SyQq0bw`-AaC%#?>I<&_dfZx@Jm6j_InzP{iM(ljh=*qxoKY+ ze0MgqIq$!vUD`Uikt1c$kAD179+NTWs*qcq4_zUa^U8c{lSWZ&1mRX1_pvMpmHcTm z;jZM!Pn*H_f1>Y=`DHV5ocU=3sbY4cI&A)#&#$|1c%3hxTDi6D8#(q$bB>8P8^?XY}HaO+A(4BAV|98SLkr!wCJYYmZu7F)%yC|@=P zxxp_{8zkh&oq{rmPw4qOwPNpGi|#czCnz+(Z9)A*(Hl=5n>3|3f7MsQAr-1u?h;ZMKL!#z@!xZY zDB;HVE?o>PX?isZ(~rmE+ZV6f8&5F*nh5&y(SEt>UWu7RZ59^=4FVW}0{%P~M&-*}m|htnsuC zl#!cqvK_TjrX~ZV@;|}p{}|;&CP@6f7pK;9qSRHJkYM0z?mPXR^YRnm3C)N#Nv62P zan^2US8+N)=1X5QFvw8OTCJvuJ=cQN%ZokvTQm z0x>$sz_Pw4^a{N1`QL~La8Hx~FFdgsV?bNDC#(dDE=P>MDCBe!8UT_mPkxp(UGnwV z;*!^@fx+%>&rz#z;~YpaeVP%4p@1aAP7j;@b0a4DJ5&5{C+w*`2>mgnh_``B{Mm|k z;=8Fz|37!5MMb9m|1x>D+q;(ZEUZM&d)MgmPH6ZMX;T5JlG1izZ)~-(w1qmTc#NO8W9v2lv8p~!u)6=Vh%nFe=zl8>+X5)u ze;l8h_Iyv;qQPEgP+~DX6Y!O`gn~_Q(0S^cA5bfvou8C*mx@*EYR503pTya6EY6+`B&%`TLrCw(2o4 zfDW?;vc{v;b`JNOKfmNF-N0G?GrW3;6}7d74JPpXqEb>r0IR@Qe0j@%UNPY9>G@AQF`p`sa3v=<0Uh>q&bEmd&%^0L zmfmOQ#ae^WR+ozG0p`fE#x0cj3BSjqa*L@v2;0TQ#l2Rg8%%Z_vg3FJslWE8PX)FD z(a|;EzR}O8q@-*Pzg-NIrl+RT3FL@C^qrkAwT%4Wi?!+uA$}Ewg`&$_{LRlr;8@Z}qnERY$d5p1*a9<4MYD+F zilsjbHAMRLz;L187#RcO_U=y0oj1r^et&O|ikkZ0hL+t4-CXQ`H}};TE<3{0@3V7o z2)O;>^u2d%`z#)T;TAN{_^1F(@b(+YD|DBvGu5FQqSvN;T#(*Gh ztxg06`0@61rQ64Y79hj8Sw2V#cqV;%tTpHyWk*T$)wzWt;jV!Y`?BeOJ_rc?(@IZI zp9bVZb}Owu=iQ4`!k)M;pLPK?p-^xinGz{7!T9dsHDJ`^`vB}WRrlZ%Fb3xH_cRK4 zxj`iLt#>=tJa7>HVJ_U~ceV|QDV9#qY|Ryc;MqIi&?r6Mt~ktOBpv}8ME_5!#N5El z0^p)0r>2Ijkobz2MOZkAl1kDzo*JkNkZ{}0=v!G?DJYD`5^$v0fsE}@&j#dKx;Z`0 z^gS2f}6#zA!@ww*{gdhZTRhvNkw4~rciCvycNd_o!rBr`IAaRJg9 zVwGmi@7@66RZh_Q)k(i-Rjz;s_aQUNQi>Vort#dLeaCFw@Ahkeo&(8vDm_iTmo*X| z0tPYChLE73L8UYB^wXIQ_}%`@CsE0_+AhQB1Pv6OI08T48>5kY#J2$9;Vl4_Oi|GX zvk07ujN{o-)Zg#m5D>(4&?5LkqWNT{q!8@UM#EhymV!0Kdf!1!W^tjGBb+NQZ^6;; zlj+@U1iS$pp4b&kf7CYLQH-hWg6P7`cT78ZnVFeUOKr;eLZcXWruH3;jo2QrZ7ri% z%#sk|TH2x{ED9+!FuywAZ$5R~Tp>SxX6D+S9+I1-e~Pll0XT@WD4IO&0zhnMqVM3O zWglX3D)J4)#9$^dgl_;^XGI0=e@?3fEP^#u0NVO*lgD>HFA-zo!Ycx{sA#4P83l#V z0(Euu&A@^Jvj3cm!PXas3VO$w4@_R|v#OM)4WB_wx+U*Vp|^Mlk$9{lUx1nH$I8C_ zvg1F0rX$z45`Qco^0`9JtcLgS> zjqol49%mAyVaE|G)t3Y`pV(+z2B5xb>FDSHu5Paku!m#7=1jD64CLfS0TPS)3d%8)6;C*2GI{z<}9XZ>Hik}&n)~E`|`Pb zZf(=6J*_7fjRL?VBqRg`?ZyKLrK6D@9UXusb^Yx}b)I|ve3KyXBavKODeoiQm~&?D z?5t}0k$rXU7~ly~844|qW=kuGSw9Ch`uRc3(|O>L(9tz)?;iwTuIJ=xJc?&A0Hy7H zy{RMh+=Mn;p%9d#XsiT;@A-84os{2Irm`iX^Q`0ea&K%j9QQMourO)Z_4PH-fG}ut z6kX_y&dDKmtC_x({vC8#J&OWJrx%EKspuf%*T{~N61X)gZf?BJqM{<8_)G;aK8zW? zvO8@L{%ao4W|P`p?fj@c*#6uX3?GBXl(YxvRl$teGQOn^5KmLlBOD9ppyJz)%Ceaa zyCR(>BqYSeL#h$aEK#PreeSxtKAH_6=Qyy%EUm->HYw~a^qQKzkJ#Bo(2~%csECfM z-R=;&x|4FpW28}l4aD}bGs)MyNE1%JQ=jI`s3c<5V5B*sXsexy<`q4_hS0AV9w zFI6izI9Mv4*lY7g5S!_c>*;SdNHp%Hj5heArz7BbHU;~_tJRIC@&tiu9iPj3{{CWT zruymbT+9712s1L=A0QI$mOb?SA@zs_-1BsOwXhQ6on0gZDDYRRv{-T$1^^ZgV7O=0Dqw zii)a`z!*q#3jo%}{6PK!Ac^-gxvr8F+c|S^sVEd%ArYAbo{WHeRYX zMlf31n}|GX_PR1R8&DG*W!^*(dV9W7{&+D6ME-`#Ss3chX@>+7L4LsjR9Fq`m8pCLPzun>Cs0F^Se63ZbC3P z_v_anfnN>*Pb*byfFGWY+C#b2YqkAKssc?Sl!K}x#j!>7g+N4LdI9J* z8HN*l|Tg#1Tw_|L%Gk*&YDl=w3|;5 z&XY+!y*{0*{4j!NF<+?Gp=SytbS2UUI6^7kzI_vZIZ|#UF^VKr1u_pHqUHPD>dd!Q z62x67Bsd4ssg;o}s98Z@8MJ*ZfZp0KnEU4Yj?u8o^X2h|LMk>9P`%=WEsr<@TwGir z+vO0mbL1Oy5|h8Rg`ev;R(_d5wYwOn48Uvhly?CgMu+ZTd3 zgS=Vk2hbmZL}}EbIVd}@!R23XNHK#I`n9}q?R|1ckXM}P=7INl_uYpAscRB!Y?uZq zbmoT`mYzsqgh^0sv~vY+jxFNn3w{6Ri{a%ai?OgvEKT1F22TdPMPkfzi%Fo>jvPsCosdq@$m4( zLb&aI!%6FL2S30!y3?U&c;Ke*oa|iy$(P)tv#BWs%Cz~PbMH-s(QbM&VsXAtE3YWq-gM$i1DB zCQbFx(1lu5$D8(EVi-dx2(O5EUvv_0&N=k_`HE2ImbH!Hhp>ofDf~QvzX^wy{lt}Kc-!wM1R{2unw**hKl z>%HQhBiY>dOsMY1uoQowr+K-C$|LPyDcdd8i-xDYV;^kiMMDqX_zOT~-rWvp_yeBH zv1`H|3WF2=m1Onh3qj-SFD;`!cp>j=OHWT;^>IZ-9VrLVdtuup z)@C&B3B4cFCQD(o_`Rw*pfhxKtdP3@XGhO7?4Nn*(j{OMycD>RS!{ocj_J|Hv^#q$ zH!JRb-zc~B?7uhvfb~M*qmJCGvoG!`bnBPv?GB2ITX(6Xjk~Kt;GtZMN;mss?+>{g zc3Hh~+cVc2~ zY^`ny!QU(`wiM|btdKCUyphz)S@zYEx!?BN4PbqjB55oA&{!rpM(fUz1xYK8u>Siz z|9{V+MXucsx*p%siQeY3N+KYi{eeU918!hZt$q5%qYuIt-vBe~;kBV*yB2F+csX_U zJb}$=XE$9lef|11u(|s4lDGaz--LTiZDP(Z8n?Mt+W|A_kI)5d@wXQ`w*&ht633m_ ztdHI87XR>7+L6+bb1vtBCE;2AZ4t>$v1c=Y>l)TOSufZO9PB*lH8suTertum+AfXr zkK#H%=Ds{@e*e$i_jT<}N1HchpPZz+Dd#OyDfAZ1A?dKbSURoI&!V(k}M6 zcbWvKeY2;^quagq>x8rbV3S2c{E(n3=+KMR;G-`v$R2_J@`r`F8agZTYJo?tFnGH9 KxvXk44ofy`glX(f`u%tWsIx;Y9 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 literal 0 HcmV?d00001 diff --git a/.moonwave/static/components/progressbar/light.png b/.moonwave/static/components/progressbar/light.png new file mode 100644 index 0000000000000000000000000000000000000000..0bac5ce776851c4bb1f41e217d9013ebf9d82fd8 GIT binary patch literal 2809 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 Nz|7d%2ybvB?(ZhDh7$k) literal 0 HcmV?d00001 diff --git a/.moonwave/static/components/radiobutton/dark.png b/.moonwave/static/components/radiobutton/dark.png new file mode 100644 index 0000000000000000000000000000000000000000..8c68d713994033a10e7846916f09bc4412be4371 GIT binary patch literal 1223 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 literal 0 HcmV?d00001 diff --git a/.moonwave/static/components/scrollframe/dark.png b/.moonwave/static/components/scrollframe/dark.png new file mode 100644 index 0000000000000000000000000000000000000000..fd27b5c9e1b6220aec79720af1844c2599d87788 GIT binary patch literal 435 zcmeAS@N?(olHy`uVBq!ia0y~yU}OZc@3Alg$@6ztGyo~a;vjb?hIQv;UIIBR>5jgR z3=A9lx&I`x0{Q#_J|V7pdU{GqN){Ftt5&Vj)YLqi_vaZZifds~{+# z!qnvAFoAzdgEJxn{)@c2e}uCv`LFoP&yTuUwGTEXXuiBMd8W$W=BVEc$&8(Ul#a}K Q4Gc{NPgg&ebxsLQ0A47T`v3p{ literal 0 HcmV?d00001 diff --git a/.moonwave/static/components/scrollframe/light.png b/.moonwave/static/components/scrollframe/light.png new file mode 100644 index 0000000000000000000000000000000000000000..3b7836594f251491811167c521be258de82bb6d9 GIT binary patch literal 434 zcmeAS@N?(olHy`uVBq!ia0y~yU}OZc@3Alg$@6ztGyo~a;vjb?hIQv;UIIBR>5jgR z3=A9lx&I`x0{J`vJ|V9E|Nq~%Z5xpH?%lgBe?Nr-S)2tPk;M!Qe1}1p@p%4<6re%+ zo-U3d6}R5ram;Hr5OED`7d^nIldt&ue)QKRJW;b2l{M_wo@?!t^q^T-fceX8m6uln zFa7h}u)Wjv-dZn*0=s>CIkw&FVolx>bB*twKaXU)oQd zi>%L9UOPV}Heubn?V+3;mjW;CwypT{&uLzL-u_$6P1g(e$8U(`+9uglrz&C0^4Mv^ zS-~&!S(Ez`*66JGFZ$|#C-m*>o>-Q-|Qb&6fQUl P3{M76S3j3^P6FNHG=%xjQkeJ16rJ$YDu$^mSxl z*x1kgCy^D%mkaO-an;k)lai8BRaI3|QnInJ2@MT(a&j^?HPzSG_wex8IzMp|P$g%9 zM`SSr1K(i~W;~w1A_XYe;pyTS5^?zL)q{c#1_Evmw>YrBXt>zO=M%Mnxuk(_N%Tow zV?LF#{P)@q&c1mX&-`GzlgAXDY{9GEn_lh-GqbEv@vQT|)o!R#J=?*4Wr2m~Brf4O zihf#qj2U*SDFznBx$5YjeEXs7X3C6&NhFNHG=%xjQkeJ16rJ$YDu$^mSxl z*x1kgCy^D%7Y*Fh8Wi!gCT=d_l9x$_?ijt{6RI6))QU UAugT$DbPs_p00i_>zopr0L-FoYybcN literal 0 HcmV?d00001 diff --git a/.moonwave/static/components/splitter/dark.png b/.moonwave/static/components/splitter/dark.png new file mode 100644 index 0000000000000000000000000000000000000000..7d8eb21f68f8c12a630953c3c228261f8725bcc3 GIT binary patch literal 1363 zcmeAS@N?(olHy`uVBq!ia0vp^w}E&S2Q!cqRz4*Lq!^2X+?^QKos)S937c5w?dGqGOhYwGjIMLM9)WE=C z=gyrS9UTV_9N4sJlb@fTy}f;CXsCsS#p%aEjvXc@CdZB)b8v772??1pWlCpfXF@`PnVDHjOUt}@^I~FRCQX`j z{P^)1GiDq)a%A!1#WprJiHV5^4<7XL@;Y_u)V6KgtgNh7u3Q-x7nhfpw{PD*OG`^b zLqmUm{}n4%Y}v9UC@9F;**Q2kc-E|0t*xy`j~-pWetl6<(V;_!y1Kgb_4WJv``4^l zvvldwu&}V2nwpA=imh9>?%K7>$H!;cvSm(AP7@|fShZ?ZL_~y#hli`H>xK;*s;a7b zdV1X5-2(#y8yXtcty^bjXP1(aQdU;h)YN2bZ0zgno0XN7l$7M@>6xCM9v&W^lasT1 z_wL%-+RV(%MT-_~+_-VhoH_ON^~uS}j*gBC7cSiW=OiaE8aN9)B8wRq_zr_G>zXeVXFrxZwPnCl-0WJN>BSD1Or%3E5?O>WD5DfmcQp; z&E}V3(zE(bzG?fQeLr1oLe6%b`JTTvod%lD&^K?^D}}5%cidMxO8aIk=FBx&*sHeX zVvE_V3vR-_e?^zJ1kz2d*-?YZf z*NiLkKKz*=!SA4vs}sDQpWi4sr6k8DPJ-{y!tGNH+>UrUIOT4(%Xj43teZiMLxWql zfqa&wwu?Iu6H<>-XpvIYdYN4;tttrm4Rkmohn|Wpg^W{#u zuuSHb%0geuEuK!Zi!vs+z!e5<$esLbg@v_)>uuJLyB~#H@7;cM%gS!f!gER|4?R?x zCaONo`*Jh$y4hSiwpGSC&i0B;6E@!5dO9ikW{!OB&7Im+Q86)F|MA(!Oxbo;{pN&z zv-8>#*UzvPetiG>SnB55t-7-=9x~&}Rg+??2owy?$j&YO?13s`Zm^ZkDat68Zm`SK(~-*KHpjzhB0$ojdV2 zznE{eTz>a@`yRVl&w}NCSE}t@XYgI+YT8!Y+@AcLb&|KjzC@mz_x8zR-E5oqdc|#( z0@~Aaer?!#_6EoO?+5d|KS)n|9{guZWvt`nGO-^4+x|U0=EGnAcB*Ob{=*0TrIXj+ z@5q(&3KN@c_hjwV>G!%SbqsU%AD{JT_1V{4@4xJ@H4&LznLq!{HLq=w)u-i;nDM{e z9atw~UaOJ)+SvGIe{s#ukN$zb-}+5`H~YG1k!;P1)XDdcB}vz}H}{*Z{rY*j>Bh5% z!~f2%?eLYC(T?_iY@O2 literal 0 HcmV?d00001 diff --git a/.moonwave/static/components/splitter/light.png b/.moonwave/static/components/splitter/light.png new file mode 100644 index 0000000000000000000000000000000000000000..9a70e0e7429f052d7f17987b10f3ca889a436347 GIT binary patch literal 1484 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 QU|MJJboFyt=akR{05*zKpa1{> literal 0 HcmV?d00001 diff --git a/.moonwave/static/components/tabcontainer/light.png b/.moonwave/static/components/tabcontainer/light.png new file mode 100644 index 0000000000000000000000000000000000000000..453bc5d00e51659167e841ff38a406af3bdce465 GIT binary patch literal 1347 zcmc&z`BM@I7)3ig(bPO@9T&}uWz%xal0wr+ay(+v1T|eFDSHJl+`_8*6QCjfjZY+Sn`oX=&Nl*Z1Vf6FQyF zWHJQ;L2YfVR;x8IFu>#SrlzK$p`k`bM!vqjb8~Y@BoYV&wzs#ty1MS$w=XR%ZF_s0 z!{K;%c%-JLc6N4}o0}&jBse)aUAuP8%*@Qi#igyS&Dq)6+S)oSEbRLA>#ttDx_R?v zNl8grS=oyhFXVFh#KZ)NL?RFfb#--mdU`6Aip%A?xw%zURqfxuKRG#>&*v{LF8cZT zDV55^#6%X0_36{6g@uLH)m0Pl$6cQO*WgIot@p&(^F7Tz+f6o-Q(lq*Vos{WU{TT zEeHfEE-v1U^pWX;G|D^|odp1xz5W+^{a9);0HD7R3=fFJ#jgl35j>>b9ii=ED%v6@ z@)6R+xPW+@2m;AKNVZLj{&$ZGAoF`Yxq4>-B@jdns*AG1B5RzN$_K1qQpkyKlFsStCUk*UEh}l2cBCe*D-8kA8 z!g6`~dr`2h+dER3qMqz~a{Ep3_JV>u*SqLE0GY`11y4ilTzP$7#A_brQpB?#MuKPS)J#|BaSl zr#Nm2TllQPT=jc{UohP%o$M;yocgx<+q>_5u-fi3g=hR;wjGL`5c~!gG&s`MsqD|t zi1KRbK)S3$1jADrSqU0$ zoF;od(7=i~SQH~D=MO=9$rldxZDUct4{r8S`J2)Q`LV())R0X>)1ng@2%iHoa^1-Y z@$$PO)yztH+WGsQRWPTGJJa>eN;Yk^@)QoeBa%}+(L#B}F%9Ju5GKj6UyG&=rRZfHXJ-rQd&lS*I*rK5@EM^zM3bzMB6w(o&AVUkb^oBaQ1#ecb) i!`J_}jQ&Mm^*@}B`{BngIcVJv0D{kk!x@1I1^)nFk+$dn literal 0 HcmV?d00001 diff --git a/.moonwave/static/components/textinput/dark.png b/.moonwave/static/components/textinput/dark.png new file mode 100644 index 0000000000000000000000000000000000000000..67c020fcba9337a70faee73025a3e48ba6568e77 GIT binary patch literal 1015 zcmVPx#1ZP1_K>z@;j|==^1poj5z)(z7MJ_Hb8X6iUB_$>%CS+t}IyyRGVPR2GQ7|wt zH#aw2TwGLCR4pwnS65d#IXNgOC@U)~U|?V=DJe8GG&?&xCnqOkV`DftIAUUAOG`^b zLqlI*UqnPiMn*;~EG#xQHd^MLPAhbP)bTlF)=YbJUmZNPe(^bUS3`@GBQD0>DW`K~!i%?UdniqA(Oj zX%H>oLPaI0XsQJisZ`OTT^9HK-)eIM>bBF;AKPxjG-sUQhFmk>F^Q5uAP@)yf;wv5+p6 zgU_PML2h=T6LQ;_%J$Hi-O)+U_XAaP z=4LT>>YFW!Y3dsKzFw{|TiUnLy|aQ8Q3rH@TR0S;^p7=VNQA04`()K4BJ4mOFb=$2x$Y;8xi(*7wVO$cXcPtOv4 zMPHu#&JOpAS*&q!*mSJo17G=PD)(VHvNH+cUv{`LADn&0vLS=sPxBNKer0-L;ZDOG zy7ZkL0w*ih_!5VjARPEWIPk$64)vl;Ef5F<0>S$v^`cDuoFu9f!hsKj10Q@BhhF() z5dH5*K|Cmnuc6W*&vY+{aHtkTM(rFhPI9BDPt?yLiVkBMmkji|5xHUR((s3Hn=*&v zHeXPIZ8+fEVLhO6<0%Pnlwc!NNtUR?Jvd;`Aj=r%M|Huw!xZA~5AMB;g@wAOf^vJpL9)L{udEAAceFvm3R@krc5JXPa7CD~dXPhl&J z{73?#Px#1ZP1_K>z@;j|==^1poj6s!&W+MgRZ*$jHe3{rzfcYR=BiadB~&n3&|`L(9rky_wn)Zb8~a`_4SdFk?HB_{QUgf+}!y1_{+=7eSLk2 ziHZ99`fqPW@g33#h{>|dU|@Tt*vfuZkCpos;a8l+1Z4IgucGM!NI|Da&n@gqUGh~ zi;IiV(b2%b!0_(EXS%w&`T6gwu@jEvUS z*2&4q+S=NjoSdhpr}Fahb#-;Qxw(&zkAQ%Hm6er^jg6(HrP9*U=jZ2pdwayh#FCPd z%*@Q)-QC{a-m9ytQ3vsdaNIlYHw@=2=iD>r8}4pK&TtAWqXQg&#RH{q-jx9=!`QiTtfMimEj zokrU%;m>Cg%Wwj87jD>cl@6`?HWUmxV|$0#)M+#WG1FzS+U&A!wZ&v~^l-(yPNz%C z=zF!r8o+(-dmg^b{a<}frxMR__gm~EdO zCapt;CaKtI)^_)!9-V4x$gA*A15^W`)c0tHFQYpbP~jQk$FnoaxuB*FJy%PrIe_ud z*l5_v5dxY>r!$CF5=FQIN}8Xb7M&{0#U6L*6*=zX%h;hf16{zU&j64;=R0dhRR;iq z(eONqJ<$d0VhpY1@^eR&ETIHDs7emyKE7OX;AbF*5a(cNMW-54#X-~eqIKfsl+iG0 zQ%jUH5)Z#yn{m16mHLFk)KFZJSiuhEzGDa9lD0Ub?C|=HqWD3N{6AG3I#%Z_Nfj`x zdDh3ZBl^S!zZ;P2jBh7TIDpN7e!dAil>3eyT>4~j##J~72I@ct)PW4naS#@GYYK%z zp-?WyEG+QWr!hkzK^@3|I*`FNaWLS>!=_G#BIyK9u83+5Yf;C$_b_9R@uwn!bJ)tP zGI2gVrjmn;Xa@+VKXB`2BMzlE_tD3Ef~;eE=d+x52)8gR5PoUg@e}UwHNvf%{+4at zf~%UieV2`zVxc|ckS};_ADArB5!(bn`Zf0X!3gObeFS?2TfLyAc3jEyxk;$VJHj!s!e4mtFqo>}gj@ORq8Xt;Gn;cVB+!M9jCAZLG${_2mw zoSTg^;p_st%*Zndk{zO{f&pE9TOpOS1q;mKZ#jE(7+B9ksQd%NBLV*|G}({-66_!p ur~?^L2QoOvL0I6eDHIBYLZJ{00RI4VRV)d=2uth$00004-| literal 0 HcmV?d00001 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 d816309f9b077f942981ed31ad91ecf67f38f015..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 606 zcmeAS@N?(olHy`uVBq!ia0vp^DL|~i!2~3;J-sS{6k~CayA#8@b22Z19F}xPUq=Rp zjs4tz5?O(K&H|6fVg?4j!ywFfJbwj9yF`g=L`iUdT1k0gQ7S`0VrE{6US4X6f{C7? zo@t7E1kf7B^PVn_AsNnZr~0mT3>0a5Ki_D#hWtdH7b!|fhg`UvQ%cm7PBj=9Oq|p? zWxZ1ehyAO4Id4lOBbql)zGhstwsg)d|LpajlauZ}(|SFuA7dWbTpgo`gEy|!AzegCMVx~tumkOYa&%@HTP}V z?x2+mI3k~aww!<7TBa{*ZJ6Hl{CXp?(#frlGiP0U|J`|^fT!5CnAKN>Tzv%CxES-T z<@(b$OIptKImOQ7I#*{RcPrDozT0o()`l%|n$&t~^BNbW#?IYedtG0qurf7X%$VY} zbOL`nb6rx9g6ki8I%t|>Hv4Lp=-vEjt+}nDn6ZoL0) z(6m=MHPuK`=S|%D@A}6d7jAqY-P;!P;lAeUZ0sQm7H!Z_h7ozy1NE OpTX1B&t;ucLK6T)VgJPd diff --git a/docs/img/button/dark/disabled.png b/docs/img/button/dark/disabled.png deleted file mode 100644 index b56de0ef4beb43be6392c9dc1a6a0a1e0cc481e6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 469 zcmeAS@N?(olHy`uVBq!ia0vp^DL|~i!2~3;J-sS{6k~CayA#8@b22Z19F}xPUq=Rp zjs4tz5?O(K&H|6fVg?4j!ywFfJbwj9yF`g=L`iUdT1k0gQ7S`0VrE{6US4X6f{C7? zo@t7E1kf5rH%}MGkPPRyQzNsE7znr&YyLmFyZ8Iuo&Q6FJoyh9@a^DGW($75`syUs zzZqNYnF?phN6vr5G|5#Vk_8)Kmy&z?+t<^ZbW$VjHl_DGo}qHG_>Wq}BvDtvCKKQ9 zwfu)Kd^+!Q`3Z~cWAlTn*XW!Ua2A{Pde^PI(9qo5N)l}cr37qCdp1seZn;Zn(eHib z>k6GOn|LIh?ho_dw5Eu`^u(M~Ovg9H${aV$Ysjc!jeNJ^E?ar*^_@?=Ki-Pekn2Bv zYM&l=GGCugXU}f|+th;}4|spn;&$M@$2ZMm*BSd%QP)D}NR85Y>Aw{hh3GIYv536k z`qXS{%%AJVd)DemN0?30jl6EUs&!&=`a$JMW>@+@zHHdPe9dhf-ks$7VS=ijAj|#W X?dKKpCbH=RL!H6X)z4*}Q$iB}Vl}l8 diff --git a/docs/img/button/dark/hovered.png b/docs/img/button/dark/hovered.png deleted file mode 100644 index 8d54fbcc6ef4557d11391dcebc6e474ce24c85fb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 600 zcmeAS@N?(olHy`uVBq!ia0vp^DL|~i!2~3;J-sS{6k~CayA#8@b22Z19F}xPUq=Rp zjs4tz5?O(K&H|6fVg?4j!ywFfJbwj9yF`g=L`iUdT1k0gQ7S`0VrE{6US4X6f{C7? zo@t7E1kf7Blb$Y)AsNnZr}~~|43ucQZ~v5k&x!DkPZN7jq-~mzR5od9uS%qVnAsE{ z$1PAP<txQ{zLWCTDF47Fjtx3YI^7JXl`xoE zDR0Ymzy4aaGsfq#&Q!1O-$WmOv=HmIea`PVaZQ53i?ZF9Uw(O5@S(!yd#ifd=9wa2 zX4tbV-L&cEo4WnqtM)!DsMvh7<7(E{TRCQeZi@xk1QM3X8Ba6ujn0a5e}AXMgc=d%Ns=c$+=6C!Ng4{e&J<8{3p(YZ zIlpyMzssK$zc+2Qj%7>dJ?9s7{)KJ!*7CQ@w|@UwXs~PN>0hZO2R3nR&|%W)W;m^c z!PH90-G2M_+p?W8&puabO|1&MfARI#8oT@1{{%!%7Fo>|>sA%vlI3Gxef3w)9s?P^ zr3$(K1g4zoa#7lS_gvcMop;}TtW*{Nc zfBc8yqZP|9bIzY3;xTcP+hRdKy?+cw)qCSAd85~ctqpVjK0Ec{)pMU2gdvuVM0AMR_u&Q6GAi4bF)=Gu@dh|X+WuHz=o bcU<1O@j{VE-9>3&8es5r^>bP0l+XkKhbsS@ diff --git a/docs/img/button/dark/selected.png b/docs/img/button/dark/selected.png deleted file mode 100644 index 0de0802d9029f74f5cd11ec833e1c5a35b50fdfc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 615 zcmeAS@N?(olHy`uVBq!ia0vp^DL|~i!2~3;J-sS{6k~CayA#8@b22Z19F}xPUq=Rp zjs4tz5?O(K&H|6fVg?4j!ywFfJbwj9yF`g=L`iUdT1k0gQ7S`0VrE{6US4X6f{C7? zo@t7E1kf7B8=fwXAsNnZr=He!DHJ)*seQz0Q$o8=&he(C)j3mnKk;jbUDMHxIC0ER z?rg=gjR`HRy}X)SkJs)Is^k%D{%Mu|ZmzY{%*``qAG53#yKlL_y8i3$l6U!!WTFJc zPj|g;{iLns-Ec~1!6ud!I*jPdg!VP7(*yRhZ7A=TI)DA@`5oscn2Vj;x$uws2G6i# z3X^^Xny}87KY4CFe}qnKTWa9H>zlmp?5K3?5{^7c$;3A^*e7(Qy((U| z^pj=I-G}G;-__R5wd$<3sBn1AcKiNb_kTNfMu%+loEG}^^qrr9VjJIb`-`O}Z`$Nu z(Y{G?3IBo87CwD*&oG9J7{^)MvDO*%IT!+@TlSW0Cwd$>BI$P8mSx-E%DJJ0ghuLdRvGN?5p)+gEA zr4`n7Fh{_Q?WDtuKfAtH@2%Qh`u+jWy}WlneeQpsb8q=P|L2u*k?obU^dsfZ2yf6~ z(&=V6t#n`$2RgGsWK)mZVjs0&t*K?Z_g>7fS+!q~hb?{c%o)!=@0{?TL+IqK9JA$@ zGfkv2x88dE@khp=1J6HqZkq6a>zacJ2?i2;?T53r8qGfIv^{8L%I3(H0F4$#mKiJG zORmk^{<>^;sa34f!~^eF%sHPv`|PySPZRDxEbv)=dHvS79+yombDOiacAZ+ksp0ZV z5q0j!8Ec*vb&6K)y;ruo*G1`NiInS!NvEGK4a(epTXphDAGPBDFSmM_M@~PTYBcl5 z-+vDaJSK&%zy8oeBdN(CfMdeapp|*srHeM-1bXQEzwC*A(Wj_?#vy` z8zvMk=H7WX@35vFFs$nKyFblM-OGzB?13@zVZu=x*X5r%!{bX7pZIiZ0#gcur>mdK II;Vst0Q>kJ>Hq)$ diff --git a/docs/img/button/light/disabled.png b/docs/img/button/light/disabled.png deleted file mode 100644 index 638591b3c5177915786d3a632d9fa5ba07ff07db..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 603 zcmeAS@N?(olHy`uVBq!ia0vp^DL|~i!2~3;J-sS{6k~CayA#8@b22Z19F}xPUq=Rp zjs4tz5?O(K&H|6fVg?4j!ywFfJbwj9yF`g=L`iUdT1k0gQ7S`0VrE{6US4X6f{C7? zo@t7E1kf7BGoCJvAsNnZr=DHSm?+ZzetvHa_fH?QpiLVbk`%?|OYa^K|8PE-`( z`u?xtt68krxuknbKCh`OJ~!i8tyS@#l9}6gp8S=1uOU@1A(ACRjB%PP1~cSD(eAry zlTRjXY;jsxxBvf&eM)ov)?a`9{Ewo>q^h;Q8?i9(m3kzM+>Q5tGVZ#7AnNV>rGEEh**6! z!Cr=Mec0;Tn|a(O>J%1Tcv%uqsmbl4Bxvbtly4^8f82Pc4_~{p*=*hEr!}0W-OS0` z8l@rDebj4d(f^4XSIpLt$=?|Rbm+|-w@FuDe=XWMM4UF!NSDIvUknHf{xDJ-+NtOrs#@wN9eTKyQIuM+ve@V zwZtRxV}*>mc(iBQ-}D*hpL;JA+I3%t`!<*MvG?Dvzb-92A6_*%U=_7(DzC(N4`%5u>)*k%8euOGg?XI5W7Gx}!k zjlz~C2WA=MG8*(KG#wCO@dgyo~Ad zxz^eGM~YqfO^2mwYIC#j z30Ah=u2pO{8%nnfu;qq^rJ};Z zlb1~K39S74czbG2h;6sj0}sH*WF7EwSNRmUkskEelF{r5}E*Jm=-_) diff --git a/docs/img/button/light/pressed.png b/docs/img/button/light/pressed.png deleted file mode 100644 index 3414a74193ee4a5125e3048300bc143c8651b2d5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 632 zcmeAS@N?(olHy`uVBq!ia0vp^DL|~i!2~3;J-sS{6k~CayA#8@b22Z19F}xPUq=Rp zjs4tz5?O(K&H|6fVg?4j!ywFfJbwj9yF`g=L`iUdT1k0gQ7S`0VrE{6US4X6f{C7? zo@t7E1kf7Bm!2+;AsNnZr`WD;Oq4jjcZ$j+N1i9Sfo7?JZo+J8+{~%Tp--4AB&P(r zO)?6KQ}YUHX`Q2A{O?ZHB}=yJ4_-cr+5fUW{`%)TrT=%mi#AM)d#3feyOMF5YeTAF zLL^Is7zT5K^R$yGK^h`iTW>uqxbpgI^;gM-0U2hqJuB_zcmHvp;CU@=v*ldB!w&=2 zUzg`=uYN3Ew?BST>!0@UW>wZjI8F z>g`fe<6v%WxN9PH_Gyu0p4se6?D?*CkxJs-u6O2st;#ips-G3kWGa^!+ u`Lxo3O&l9^m~^@s(3y8q4fPG>>X}cQ9OPM5VXF>I7!01SelF{r5}E)7$OhB^ diff --git a/docs/img/button/light/selected.png b/docs/img/button/light/selected.png deleted file mode 100644 index 0a218f0ebf19f2669cde1116093ae8771bb9e13c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 703 zcmeAS@N?(olHy`uVBq!ia0vp^DL|~i!2~3;J-sS{6k~CayA#8@b22Z19F}xPUq=Rp zjs4tz5?O(K&H|6fVg?4j!ywFfJbwj9yF`g=L`iUdT1k0gQ7S`0VrE{6US4X6f{C7? zo@t7E1kf5L15X#nkPPSK8)jS`Yzq!leR_9aL#e}!xtqmADnez!#RE!=y=-Ef873P% z92PgaGD-332wliXnAiYQqEN*$ZNdf)W@ct??(U{Zr;fbjzL+v`;@-)>A6_*%U=_7(DzC(N4`%5u>)*k%8euOGg?XI5W7Gx}!k zjlz~C2WA=MG8*(KG#wCO@dgyo~Ad zxz^eGM~YqfO^2mwYIC#j z30Ah=u2pO{8%nnfu;qq^rJ};Z zlb1~K39S74czbG2h;6sj0}sH*WF7EwSNRmUkskEelF{r5}E*Jm=-_) diff --git a/docs/img/checkbox/dark/disabled-false.png b/docs/img/checkbox/dark/disabled-false.png deleted file mode 100644 index a1d14ddcafc5e39473245aa5e25390be17e4c6cf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 440 zcmV;p0Z0CcP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D02p*dSaefwW^{L9 za%BK;VQFr3E^cLXAT%y9E;VFFSW*B00VzpDK~zXf?ULDUgFp;L`$eoNyFyvQz61#W z|0_L-QCd~0gp>xQQts3E+8!S~yeUttGo0(XAFCy8+h~D%U#Az`mmUtMs_N-)qA(=?^B?|aL#q>Q12qz;FJlx+dG-|r2> zFijIgQcRua8B^1=FbvzarG|p0X{f=>vdr_mjo@tCE{Xy&m}vPil`~Ed1Z1z`+AZLA zyWR14^nIV*up}Pw`_$_hxIE9r6m0Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D02p*dSaefwW^{L9 za%BK;VQFr3E^cLXAT%y9E;VFFSW*B00aQsuK~zXf-ICdkfO=Vy2o7i81O2#Rd7 z2z;b|GsK zxIUV8l->W9oT{o-RW*x#-7R}Gxzp)1o6VZVJv{-gkEY|l2dOm&;C9<~Zy1n%!1hJ4SMvrsa9QTCK>2ka)z~uI(JS!{H#N z=r6cFnsywR99U5l@mRbR|C7Lx4K$dZ=dnu}=p3XkrfHhgU`nXJ*ED)Ua`H6DbzPeA zGl4}gaU2UAS+c~$0ZR_jG)+^?Fbw7MILHmMD|$4!m+H~vUaBh`uMXcp$+2;Ch5JxV ZCO>Gsc+OQqPzwM6002ovPDHLkV1g{e%q;)_ diff --git a/docs/img/checkbox/dark/disabled-true.png b/docs/img/checkbox/dark/disabled-true.png deleted file mode 100644 index eaa3862fbba1e618e658436f69504f4d8bd0f04f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 561 zcmV-10?z%3P)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D02p*dSaefwW^{L9 za%BK;VQFr3E^cLXAT%y9E;VFFSW*B00isDnK~zXf&D3d50znXlVNb*^AQ1L_U+;ev zA5vtZI64y(k?#lTs;=ej>SkUFPpm6kv)O#htw?)}u5enu{-}Dr9*cgzA8C(KHe938 z7>~!n_grw8keyDaUa!ae1zfRMJfF`u<9nrsWxyF5vc+Oy!Tbt$JRZyC^36!oU%?sM zd_F%M4xLUXJpTq~HpAgCdHWWl(MW>d#F+<9jPscHU@&0nYVB=^-0gNsMVO{cTa(Eo z!oG!*Go4PON^qDKbGck5WRwT4+wE2=m1?yL(Uz{4%Z0O4DtWk@%|?uX^?EIaQ{Psr zl`%OHaTJA+S*z|77+9e^aBd(T112eVU9EZ4x0^`W3^;-XPCo+^$_#&^Ot@ODw%_mD z?Y7w%lBs{}nykUiX0vdHvf;=>!6(jB_;{hAOgLB$=Ca&_Iss=kXgEDzyTYJL)R)uc zh~d;U7K!fR$ln6loimgQ7gs|PHS?HY8O&<63OKW5a`M26MD@4ZEvN5P98aPOu^UQ( zQ;}}QXMKhHQ>DQDp#Oo>HK!u&F}lJ%sS1U6Z}=TWSddI!00000NkvXXu0mjf?*;Ph diff --git a/docs/img/checkbox/dark/false.png b/docs/img/checkbox/dark/false.png deleted file mode 100644 index 033a84b9bc300c9aaae56e75d70296e3cb25d9db..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 531 zcmV+u0_^>XP)N2bZe?^J zG%hhNHDpIvQUCw}kx4{BR7gwZmb;3=P!NXqIV{#{TcfCm0r66#5*rH}5iJ6C5iHb7 z3$d`0pxA_1C}LrXh#(RK@0yT@+FvFsTdW|sdkE}TIdkS@KF&8#R(pRvg`Hg1Yf)=x7}`eo_B(uaJ$_3e0GAzu#iKK zTCHXn#(uwVG#ZpSolc!j2bGJ(B9qBbWj>!@E|)0lcDo$Mv5-R>Ns?l*STdR1Y&JMT zSIe?0l}aQMsaC6+rlBkzk58x5R4Nq?hkLzVp-^BU2eR4ha5%){*|wcdr_uGkb7T+< z2Fv9#PVdy~bry1hAgtGGQ55ku^Z6V@{4N&?g|L5tKmc;GEK`zTAy+DuMxzlXSS%Kg z$0NyMtk6ZW-EOO@in2r^vD@vicF@h`a?r&-n5Kyz^c~_C8F2YRLbtDx*ZYiuf(PJe4oPB VLP5uzg5UrE002ovPDHLkV1nYe=P&>O diff --git a/docs/img/checkbox/dark/hovered.png b/docs/img/checkbox/dark/hovered.png deleted file mode 100644 index 7b5b7f2745c34f450a9f5af61a8458ce18e470fe..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 791 zcmV+y1L*vTP)N2bZe?^J zG%hhNHDpIvQUCw~l}SWFR7gwBmP>0IQ51mtXT0OvaU5%mM2e`%CTY8|*oC1*X{e-V z7fP{|EUd*YYJ_gI8!IiLrNw|KWF?3jL&TNoM!|wuM5y(F4@|IUyc3a{I3cvD-$Le` zd#~r?oa^I;bb(>yluG5zOM{PH4~!g?RX{i^*Jw1f zS}hx#$6u0zTSf4hhY13a^w|f+`|kqdJ)F3jT(8$-gNvCfhY%u_;>{OGD8U}gKy9z+ z{hrw7n@DrYkt7)i1ZuTftJMkygOGu$-EKD;jl#}Ssbn^r@lLzlUM`nm+uq)uOeUKv z2Tgh!h6#!v`I#0XLSQh_Us|L$!F&|X;FNJB0EEe0|-sb2Wp)pdS0{yikylvy_?o#i*DI#Tp*05eeXMkbR9s!r}4^c4)?Yt`fN!0LqE-QBrz078QG2hcpl#~%n zoPtwsV`HOQt-4$;=uM$efFVxHffhVN2IS)LI8I`?}*D(a5%iQw3N%`+U<5CkvKd&#B!BNC7DcuYc`uT7!3Fz zm=%jfNTyP$m6a99zjxqr%eau@R-}M{P)N2bZe?^J zG%hhNHDpIvQUCw}wn;=mR7gwBmd&dHVHC#i-(hjBY<+}8$;ZMAoqdYourRh%He7x7QcyrEs-Z{UXa~8hLAFL~!(P;esv*;Nc zUE$DjI2@Wxru$PAMNI~$!C;_gY^1{J(cDhD{U10z8cqHIIIGoqI2_bKUnn~sk81Fv zXPsLvmmZHt4Sv%L;PhxT`N!b)`@I_Ii-mLOk;~;4i^Y1qE))us+3j|fN(GhE=`<3F zP-Q$GpUq|{t5&NPi-m=AXv6co*Xs=g0Tgn5dhgu`KUz3m(k*zNXoI*r?#^7%Xq=R%>7BuS}M3f_!H zBMk8#&f#!i|70mf$c};38Qrm)q?&%KU!+YPG`J z0XG(l0T=slI-T%?zC(I}bLwmqrBo_mhp0000N2bZe?^J zG%hhNHDpIvQUCw~ib+I4R7gwBmdlG$Q5eAcJ6z<-bA4VP_loE+_<&qFRtO0pjs};c zMM^>{M7l5|7a^iZC0c016v~XllngT9_!u9!jkdLF7ZK64?7N;jBKf$(P?Nuf-0wT* zcz%4}C)Z2l3d70?g~H2c1|Pc^SUI>-v;>4iVhQ^x5(xwXeC%T2mqQAT;?7efBf)RQ zACwF{&%ms^CDu)nKY2}*_kyL>_*oHEX>D?1K zlbA^J%E29>P^i&pv|6o5B!V;DZa0xgz|OtBJ-gkGce-4z{r!E|mP{s z&_Vg_Cu|83A-Qscu6$R;-?2Z1Q+Vaz8o%GazP@fYn-32UVF_*>4u^q2K&R6MgTZRG z3fqiE7Ad_jazILr{I;d zTCE2M2MohNZ7P)tG;vyvrfHZzwOS2w>2w+=F?@1kWH;|aH?_OJ6n8Vz4aM-vdA;6Z zu?QXXcs!j>2g^ZQK^Kzoc)U<3z&4Y~baZqCV+XoUrxSEx9tMK}JmBxa{3!>el5IW! zGeT$LGE>Zz<9ah1#bPm-AsD81yPe5o%H=YatJmw?_KCjp7 zA+x=`9gRluDrQCw5KG4I1A+^un3emd%#&Npe~?@0e+;0000< KMNUMnLSTY}HCBEA diff --git a/docs/img/checkbox/light/disabled-false.png b/docs/img/checkbox/light/disabled-false.png deleted file mode 100644 index 2507de7de987c956174e7b541a17a2fc0fe92667..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 519 zcmV+i0{H!jP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D02p*dSaefwW^{L9 za%BK;VQFr3E^cLXAT%y9E;VFFSW*B00eDG7K~zXf?UKK1!ax+q`)5Zqm?(;Ykbn+G z1&MAHS69D7>(Xq^(w~TrM}C&t2E8)oP>BCoPL5o4nl4= zo262z!C-*D$z(EU;(1kQp~ar(L9SY@QjiEjZa5qki$!!0i)b`Ta%d}bx7%%_(NI+t zY0Kr3=Xs1BbSIMubTJR%a2N}Ehxh|Izc&oiY&J1NilSi5HBHOs^CTw-LZwoHYoSnp z7F~pyEXxR%N+paYBH3)V)oRfxajV>2`44i}L)U`gB=?{kkMGmNMU+&A7!v>h002ov JPDHLkV1gu6$&Q;_O7<RXp%!_K^oFo>p`HI>KM8 zpW1Zhyv=Vp3m0dpcLCq_m-4mpwKvyxq*nAERJ6Nhx&Hd@xb-*NC+ai^Uo2?f^uEz~ z%aY<1Ste4A4hP5clqU-5G}j;`Z9d|$CGziO7J)>6j(R)^wZBjbJ)}uJ>*M$ zf9T=NvfX>5);{~Z^Zxt$Ic6GLZsx2F%iew0Z1&jpL`3olILspTi&cAUIjPRclqT6gBx$lF3yh7X;Zwnk4vZL zM}-a3x)802o2O5j?%QuyUClC^eRS*5q=z3XHk2KZPx#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D02p*dSaefwW^{L9 za%BK;VQFr3E^cLXAT%y9E;VFFSW*B00vAa{K~zXf&6G!r0znW)_h*l&m{3fp=)r^$ z#Dt=t3n~~e?M3h);=zL;UObpk3>fy48iqkBMCGQ_sa~(|cDu+8u8Jb^PlXmrd^{c@hrcJ9B*f)FCX+!m zVHS->`2{6J<$ArI-|t5S5hxT2l0#XcyW8zhlVC82WsAi^rBb2opgSB6p^JWyN~H+s zJ;bdXNT<^(9y^l_l5Ggm-|%&0@?kEP)N2bZe?^J zG%hhNHDpIvQUCw}q)9|UR7gwhl0B$FVHn3xBqSk*Q%YG3Y;1~RQm4*#2FhYJDVwZx zHiPUI8-sE;n@yQ*@{t80U*-GnzVCJKaCPs^_2$<7P3L*e^SsZm_kFn26LXP!`3aa&co$)yPZa(p}}oXrdF$I@W#DzX0v%T8qwf0o?T9>)zaWLEadk4y)4UC ztJP>UW-=LMFjc41B@zjX#o}~2o6RPvtkr4`hXZBtc>H)gvXH}=LZL7m4!hkh&-3H) z7*iDrMLwUO&1Sh=&h2)iW-u7|{r*m;vs^A?u~@ZQWg!O|jfNyixIB*ITCEnQUUv@n z3WLpNQ!Ezo_ewUKWg*vYw{13CsZ_$ZAbN oEB8?Tg538ZZEU#6Jt?Qt8Ng~!SEjS`9smFU07*qoM6N<$f_~2NApigX diff --git a/docs/img/checkbox/light/hovered.png b/docs/img/checkbox/light/hovered.png deleted file mode 100644 index a754dc1297db60b06e4952b7f11db5bb75de270e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 801 zcmV++1K#|JP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D02p*dSaefwW^{L9 za%BK;VQFr3E^cLXAT%y9E;VFFSW*B00+LBYK~zXf&6Pc9T2U0oU$vrEQX@J@1*wY# z-Ly+B3c6Gf+#D37;7|%qw$cvb(m~0VL9!bgV_TFWNS4@1Xh9+^U4#Z~G-Oe$#;?|= z_x8NpSCb}wHMFUJ4sy@&T+fel&U;dLiN(Mr{zT;3)rf_oHt{sOwuyN!nNK(`9h`U@ z2MYK`oZ(w8xa%?ed=J;ZBl|ndiCqScrfGpdz&g_fX?#APb$)3M+|6xV?W5>Ek|Y@S zBAiqztyZh7Gf9634yMM9{YJ*$!jU4{KSuX=*vRGJghCG~=LO-0!{OT6nnIyiU0n@@LJ)zfB9X}F^T}i~jYgBt=SgNJlToYHkmmJz z5kj-zph@A+2qyS!k7*&S*S#Iw{DBfBB#N_Si@-sdjg5_?qoad^1G!vYtJR_i%$sNZR0qx zxp2v3(rUFf8jVycwX(88;J92a;I>*Vzu#}O*Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D02p*dSaefwW^{L9 za%BK;VQFr3E^cLXAT%y9E;VFFSW*B00nJH7K~zXf-I6`1fl(aB-$+P83`!}BfsIX3 zOiI}dl*MXNHd)DLklkWqQ0`{4DYH#JvLNKEd_SCXPj_%`uCDj$KEuEFJm-JT|JU;# z;rzzB!hQTi-FRDm@e+=7vOj_%;6>X2%J)>qzk^pNbYbr-Dyu83+XMTl@XKPN&1OTd&uRMq{_zVVq1R!w0?SCJM)+Va_+n!6!$l0000N2bZe?^J zG%hhNHDpIvQUCw~kV!;AR7gwBl}m_HVHC%2ny866q6HVf`*3_m@@ z&2Px^7BgZOfg6v<91e$e77NnYY&Pxu(h9iiySP3?(IX_>Bi-|GB9W+GuhY&v{T(=% z8Z-7A8G8qZ3+U(=-QA}n7lY$+xwNxb%iw@a^a4M8kGVTE2MqLa4iCoYQI0&X2{)Nc zg2A9vD&5-J^85V|fvP+n&+T@L#bTvWnaky<%yc@fP$(eH<#Hi}mcc=j0-q2}@W~INwCaBV^9t+iCpaEBf3)H(s5Ptpf+jX0ukS6`Ds7L_VK~ zsB;IE+T4@a;^2Kwudh6b8O~2ju!!dg$dB+#e&;0sVSgAFfa+$Yip3JPy6l zXf!ay({O{q0QL_e0OxQxs34^kaFpH8lG7ZH(X)>T-fgouWh>zliG;~yYPDL)WRlP4 zQ*cZs6L347j>qFMo6RuRa=9!N3SsT~{eC8s8I4BM>6FD{!GZpvpE?T%t$Oed1KG{w z3DeBx!qKxK2(Q=M?RIrKoj@S4+wBH}fr69EX*Qb> z(Q36mpAXQf1khPHF!-~AhX9_5(?QD4!~Ijufm>q#2X1{)*DgmS+=dF{A5~n6C`V$( Qga7~l07*qoM6N<$f}Ij#$^ZZW 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