Skip to content

Instantly share code, notes, and snippets.

@cxmeel
Last active May 29, 2023 07:09
Show Gist options
  • Save cxmeel/61eea881188c977895ed54065b216233 to your computer and use it in GitHub Desktop.
Save cxmeel/61eea881188c977895ed54065b216233 to your computer and use it in GitHub Desktop.
A small utility module for creating React/Roact components with default props.

This has been replaced with default-props. See the project page here:

https://github.com/csqrl/default-props

withDefaultProps

A small utility module for creating React/Roact components with default props.

Setup for Roact

withDefaultProps is already setup for React. If you'd like to use this module with Roact, swap out local ReactChildren = "children" for local ReactChildren = React.Children.

Use [Roact.Children] = { ... } if you want to specify default children on an object, rather than children = { ... }.

Hooks

Functional defaults also support Roact Hooks.

local Button = default.TextButton (function(props, hooks)
  local hovered, setHovered = hooks.useState(false)

  return {
    BackgroundTransparency = hovered and 0.5 or 0,

    [Roact.Event.MouseEnter] = function()
      setHovered(true)
    end,
    [Roact.Event.MouseLeave] = function()
      setHovered(false)
    end,
  }
end)

return Hooks.new(Roact)(Button)

cx

The cx utility function provides a way to select values given a condition. cx accepts an array of inputs, in the format of { value, condition }. This ensures that results are evaluated in the order specified.

To specify a default value, include a raw value in your conditions array. It does not need to be the first value within the array, but only the first non-table value found will be considered.

local cx = default.cx

local Button = default.TextButton (function(props, hooks)
  local hovered, setHovered = hooks.useState(false)
  local pressed, setPressed = hooks.useState(false)

  return {
    BackgroundColor3 = cx {
      -- We want "pressed" to take priority over "hovered" 
      { Color3.fromHex("#ededed"), pressed },
      { Color3.fromHex("#dedeff"), hovered },
      Color3.new(1, 1, 1),
    },

    [Roact.Event.InputBegan] = function(_, input: InputObject)
      if input.UserInputType == Enum.UserInputType.MouseMovement then
        setHovered(true)
      elseif input.UserInputType.Name:match("^MouseButton%d+$") then
        setPressed(true)
      end
    end,

    [Roact.Event.InputEnded] = function(_, input: InputObject)
      if input.UserInputType == Enum.UserInputType.MouseMovement then
        setHovered(false)
        setPressed(false)
      elseif input.UserInputType.Name:match("^MouseButton%d+$") then
        setPressed(false)
      end
    end,
  }
end)

Examples

local React = require(path.to.ReactOrRoact)
local default = require(path.to.withDefaultProps)

local Padding = default.UIPadding {
  PaddingTop = UDim.new(0, 8),
  PaddingRight = UDim.new(0, 12),
  PaddingBottom = UDim.new(0, 8),
  PaddingLeft = UDim.new(0, 12),
}

local Corners = default.UICorner {
  CornerRadius = UDim.new(0, 4),
}

local Button = default.TextButton {
  BackgroundColor3 = Color3.new(1, 1, 1),
  TextColor3 = Color3.fromHex("#1a1a1a"),
  FontFace = Font.fromEnum(Enum.Font.GothamBold),
  
  children = {
    Padding = React.createElement(Padding),
    Corners = React.createElement(Corners),
  },
}

-- Extend the Button component by overriding props
local PrimaryButton = default(Button) {
  BackgroundColor3 = Color3.fromHex("#00a2ff"),
  TextColor3 = Color3.fromHex("#fafafa"),
}

-- Pass a function to access props in the defaults
-- Note that this will disable merging props with defaults
local AltButton = default.TextButton (function(props)
  return {
    BackgroundColor3 = props.primary and Color3.fromHex("#00a2ff") or Color3.new(1, 1, 1),
    TextColor3 = props.primary and Color3.fromHex("#fafafa") or Color3.fromHex("#1a1a1a"),
    FontFace = Font.fromEnum(Enum.Font.GothamBold),
    Text = props.Text,
    
    children = {
      Padding = React.createElement(Padding),
      Corners = React.createElement(Corners),
    },
  }
end)

local function App()
  return React.createElement("Frame", {
    BackgroundColor3 = Color3.new(0, 0, 0),
    Size = UDim2.fromScale(1, 1),
  }, {
    Button = React.createElement(Button, {
      Text = "Hello, world!",
    }),

    PrimaryButton = React.createElement(PrimaryButton, {
      Text = "Hello, world!",
    }),
    
    AltButtonDefault = React.createElement(AltButton, {
      Text = "Hello, world!",
    }),

    AltButtonPrimary = React.createElement(AltButton, {
      Text = "Hello, world!",
      primary = true,
    }),
  })
end

FFlags

Feature flags enable access to unstable or experimental features.

ALLOW_MERGE_FUNCTIONAL_DEFAULTS: boolean = false

Allows props to be merged with the result of defaultProps when defaultProps is a function by removing props that can't be applied when the component is a Roblox Instance. This may cause performance issues.

Example

Because invalid Instance props are removed, we can now pass props like "primary" to the TextButton without it causing errors. It also means we don't need to specify the Text property within the defaultProps return table.

local Button = default.TextButton (function(props)
  return {
    AutomaticSize = Enum.AutomaticSize.XY,
    AutoButtonColor = false,
    BackgroundColor3 = props.primary and Color3.fromHex("#00a2ff") or Color3.new(1, 1, 1),
    TextColor3 = props.primary and Color3.fromHex("#fafafa") or Color3.new(),
    FontFace = Font.fromEnum(Enum.Font.GothamBold),
    TextSize = 14,
      
    children = {
      Corners = React.createElement(Corners),
      Padding = React.createElement(Padding),
    },
  }
end)

local function App()
  return React.createElement("Frame", {}, {
      AcceptButton = React.createElement(Button, {
        primary = true,
        Text = "Accept",
      }),
      
      DeclineButton = React.createElement(Button, {
        primary = true, -- Keep it "primary" to apply the white text color
        BackgroundColor3 = Color3.fromHex("#ff0037"), -- Override the background color
        Text = "Decline",
      }),
      
      CancelButton = React.createElement(Button, {
        Text = "Cancel",
      }),
  })
end

COLLATE_BINDINGS: boolean = true

Merges signals into a single function and returns the output of each callback as a tuple.

Example

local Button = default.TextButton {
  [React.Event.Activated] = function()
    print("Button clicked.")
  end,
}

local function App()
  return React.createElement(Button, {
    Text = "Click me!",

    [React.Event.Activated] = function()
      print("Hello, world!")
    end,
  })
end

Upon clicking the button, "Button clicked." followed by "Hello, world!" is displayed in the output.

--[[
This is a lightweight implementation of the Sift library
containing only the methods required by this module.
https://csqrl.github.io/sift/
--]]
local SiftImpl = {
None = newproxy(true),
Dictionary = {},
}
function SiftImpl.Dictionary.merge(first, second)
local output = table.clone(first or {})
for key, value in (second or {}) do
output[key] = if value == SiftImpl.None then nil else value
end
for key, value in output do
if value == SiftImpl.None then
output[key] = nil
end
end
return output
end
function SiftImpl.Dictionary.filter(dictionary, filterer)
local output = {}
for key, value in dictionary do
if filterer(value, key, dictionary) then
result[key] = value
end
end
return output
end
-- Begin module --
local React = require(path.to.ReactOrRoact)
local Sift = SiftImpl
local Dictionary = Sift.Dictionary
local ReactChildren = "children" -- Roact.Children
local FFLAGS = {
ALLOW_MERGE_FUNCTIONAL_DEFAULTS = false,
COLLATE_BINDINGS = true,
}
local EVENT_PROPMARKER_PATTERN = "^RoactHost.*Event"
type DefaultProps = { [any]: any } | ({ [any]: any }, ...any) -> { [any]: any }
local InstancePropertyRegistry = {}
local function instanceHasProperties(className: string, propertyList: { string })
local propertyRegistry = InstancePropertyRegistry[className]
if propertyRegistry == nil then
InstancePropertyRegistry[className] = {}
return instanceHasProperties(className, propertyList)
end
local testInstance = Instance.new(className)
for propertyName in propertyList do
local success = pcall(function()
return testInstance[propertyName]
end)
propertyRegistry[propertyName] = success
end
testInstance:Destroy()
return propertyRegistry
end
local function collectBindings(props)
local bindings = {}
for key, value in props do
if tostring(key):match(EVENT_PROPMARKER_PATTERN) then
bindings[key] = value
end
end
return bindings
end
local function collateBindings(...)
local resolvedBindings = {}
local callbacks = {}
for _, props in { ... } do
if typeof(props) ~= "table" then
continue
end
local bindings = collectBindings(props)
for symbol, callback in bindings do
if not callbacks[symbol] then
callbacks[symbol] = {}
end
table.insert(callbacks[symbol], callback)
end
end
for symbol, callbackArray in callbacks do
resolvedBindings[symbol] = function(...)
local output = {}
for index, callback in callbackArray do
output[index] = callback(...)
end
return unpack(output)
end
end
return resolvedBindings
end
local function isHostComponent(component)
return typeof(component) == "string"
end
local function withDefaultProps(component, defaultProps: DefaultProps)
local isDefaultPropsFunctional = typeof(defaultProps) == "function"
local isComponentHostComponent = false
if FFLAGS.ALLOW_MERGE_FUNCTIONAL_DEFAULTS then
isComponentHostComponent = isHostComponent(component)
end
return function(props, ...)
local resolvedDefaultProps = if isDefaultPropsFunctional
then (defaultProps :: any)(props, ...)
else defaultProps
local resolvedOtherProps = if isDefaultPropsFunctional then nil else props
local resolvedBindings = nil
if FFLAGS.ALLOW_MERGE_FUNCTIONAL_DEFAULTS and isDefaultPropsFunctional and isComponentHostComponent then
local instanceProperties = instanceHasProperties(component, props)
resolvedOtherProps = Dictionary.filter(props, function(_, key)
return instanceProperties[key] == true
end)
end
if FFLAGS.COLLATE_BINDINGS then
resolvedBindings = collateBindings(resolvedDefaultProps, props)
end
local newProps = Dictionary.merge(resolvedDefaultProps, resolvedOtherProps, resolvedBindings, {
[ReactChildren] = Dictionary.merge(resolvedDefaultProps[ReactChildren], props[ReactChildren]),
})
return React.createElement(component, newProps)
end
end
local function cx<T>(conditions: { [number]: { [number]: T | any } | T }): T?
local defaultValue
for _, input in conditions do
local inputType = typeof(input)
if inputType ~= "table" or #input < 1 then
if input ~= nil and inputType ~= "table" and defaultValue == nil then
defaultValue = input
end
continue
end
if input[2] then
return input[1]
end
end
return defaultValue
end
local function metamethodWrapper(_, component)
return function(defaultProps)
return withDefaultProps(component, defaultProps)
end
end
return setmetatable({
None = Sift.None,
cx = cx,
}, {
__index = metamethodWrapper,
__call = metamethodWrapper,
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment