From 23c4e2303fdba68cab8ea317733a366fcf7b1d98 Mon Sep 17 00:00:00 2001 From: sircfenner Date: Fri, 5 Jul 2024 16:21:31 +0100 Subject: [PATCH] DatePicker component (#47) --- .../static/components/datepicker/dark.png | Bin 0 -> 3717 bytes .../static/components/datepicker/light.png | Bin 0 -> 4086 bytes CHANGELOG.md | 1 + moonwave.toml | 2 +- src/Components/DatePicker.luau | 377 ++++++++++++++++++ src/Components/Foundation/BaseButton.luau | 21 +- src/Constants.luau | 5 + src/Stories/DatePicker.story.luau | 24 ++ src/init.luau | 1 + 9 files changed, 424 insertions(+), 7 deletions(-) create mode 100644 .moonwave/static/components/datepicker/dark.png create mode 100644 .moonwave/static/components/datepicker/light.png create mode 100644 src/Components/DatePicker.luau create mode 100644 src/Stories/DatePicker.story.luau diff --git a/.moonwave/static/components/datepicker/dark.png b/.moonwave/static/components/datepicker/dark.png new file mode 100644 index 0000000000000000000000000000000000000000..1091b3fa6c1f86786a68aaf953b54ec7768bdeca GIT binary patch literal 3717 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 UU(59PzZU{cRb7>8CEKw70}{~bbN~PV literal 0 HcmV?d00001 diff --git a/CHANGELOG.md b/CHANGELOG.md index 74de922..a2a48d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Fixed image links in documentation - Added OnCompleted prop to Slider +- Added component: DatePicker ## 1.0.0 diff --git a/moonwave.toml b/moonwave.toml index 82476f8..6440c0f 100644 --- a/moonwave.toml +++ b/moonwave.toml @@ -22,7 +22,7 @@ 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"] +classes = ["Background", "Button", "Checkbox", "ColorPicker", "DatePicker", "Dropdown", "DropShadowFrame", "Label", "LoadingDots", "MainButton", "NumberSequencePicker", "NumericInput", "PluginProvider", "ProgressBar", "RadioButton", "ScrollFrame", "Slider", "Splitter", "TabContainer", "TextInput"] [[classOrder]] section = "Hooks" diff --git a/src/Components/DatePicker.luau b/src/Components/DatePicker.luau new file mode 100644 index 0000000..e16a227 --- /dev/null +++ b/src/Components/DatePicker.luau @@ -0,0 +1,377 @@ +--[=[ + @class DatePicker + + An interface for selecting a date from a calendar. + + | Dark | Light | + | - | - | + | ![Dark](/StudioComponents/components/datepicker/dark.png) | ![Light](/StudioComponents/components/datepicker/light.png) | + + This is a controlled component, which means you should pass in an initial date to the `Date` + prop and a callback value to the `OnChanged` prop which gets called with the new date when + the user selects one. For example: + + ```lua + local function MyComponent() + local date, setDate = React.useState(DateTime.now()) + return React.createElement(StudioComponents.DatePicker, { + Date = date, + OnChanged = setDate, + }) + end + ``` + + In most cases the desired behavior would be to close the interface once a selection is made, + in which case you can use the `OnChanged` prop as a trigger for this. + + The default size of this component is exposed in [Constants.DefaultDatePickerSize]. + 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 BaseButton = require("./Foundation/BaseButton") +local CommonProps = require("../CommonProps") +local Constants = require("../Constants") + +local useTheme = require("../Hooks/useTheme") + +type TimeData = { + Year: number, + Month: number, + Day: number, +} + +local TITLE_HEIGHT = 28 +local OUTER_PAD = 3 + +local LOCALE = "en-us" +local ARROWS_ASSET = "rbxassetid://11156696202" + +--[[ +ideas: +- props for minimum and maximum date +- selecting a date range +- localization +]] + +local dayShortName = { "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun" } + +local function getDayNumberText(day: number): string + if day > 9 then + return tostring(day) + end + return `{string.rep(" ", 2)}{day}` +end + +local function getDaysInMonth(year: number, month: number) + if month == 1 or month == 3 or month == 5 or month == 7 or month == 8 or month == 10 or month == 12 then + return 31 + elseif month == 4 or month == 6 or month == 9 or month == 11 then + return 30 + elseif year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0) then + return 29 + end + return 28 +end + +-- 1 = monday, 7 = sunday +local function getDayOfWeek(year: number, month: number, day: number): number + local time = DateTime.fromUniversalTime(year, month, day) + local dayWeek = tonumber(time:FormatUniversalTime("d", LOCALE)) :: number + return 1 + (dayWeek - 1) % 7 +end + +local function DayButton(props: { + LayoutOrder: number, + Fade: boolean?, + Text: string, + Selected: boolean, + Disabled: boolean?, + OnActivated: () -> (), +}) + return React.createElement(BaseButton, { + LayoutOrder = props.LayoutOrder, + Selected = props.Selected, + BackgroundColorStyle = Enum.StudioStyleGuideColor.RibbonButton, + BorderColorStyle = Enum.StudioStyleGuideColor.RibbonButton, + TextTransparency = props.Fade and 0.5 or 0, + Text = props.Text, + Disabled = props.Disabled, + OnActivated = props.OnActivated, + }) +end + +local function MonthButton(props: { + Position: UDim2, + AnchorPoint: Vector2?, + ImageRectOffset: Vector2, + Disabled: boolean?, + OnActivated: () -> (), +}) + local theme = useTheme() + local hovered, setHovered = React.useState(false) + local pressed, setPressed = React.useState(false) + + local color = Enum.StudioStyleGuideColor.Titlebar + local modifier = Enum.StudioStyleGuideModifier.Default + if props.Disabled then + modifier = Enum.StudioStyleGuideModifier.Disabled + elseif pressed then + color = Enum.StudioStyleGuideColor.Button + modifier = Enum.StudioStyleGuideModifier.Pressed + elseif hovered then + color = Enum.StudioStyleGuideColor.Button + modifier = Enum.StudioStyleGuideModifier.Hover + end + + return React.createElement("TextButton", { + Text = "", + AutoButtonColor = false, + Position = props.Position, + AnchorPoint = props.AnchorPoint, + Size = UDim2.fromOffset(15, 17), + BorderSizePixel = 0, + BackgroundColor3 = theme:GetColor(color, modifier), + [React.Event.Activated] = function() + if not props.Disabled then + props.OnActivated() + 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) + end + end :: any, + [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 :: any, + }, { + Icon = React.createElement("ImageLabel", { + Size = UDim2.fromOffset(5, 9), + Position = UDim2.fromOffset(5, 4), + BackgroundTransparency = 1, + Image = ARROWS_ASSET, + ImageColor3 = theme:GetColor(Enum.StudioStyleGuideColor.MainText), + ImageRectSize = Vector2.new(5, 9), + ImageRectOffset = props.ImageRectOffset, + ImageTransparency = if props.Disabled then 0.7 else 0, + }), + }) +end + +--[=[ + @within DatePicker + @interface Props + @tag Component Props + + @field ... CommonProps + @field Date DateTime + @field OnChanged (newDate: DateTime) -> () +]=] + +type DatePickerProps = CommonProps.T & { + Date: DateTime, + OnChanged: ((newDate: DateTime) -> ())?, +} + +type PageState = { + year: number?, + month: number?, +} + +local function DatePicker(props: DatePickerProps) + local theme = useTheme() + local chosenPage, setChosenPage = React.useState({ + year = nil, + month = nil, + } :: PageState) + + local selectedTime = props.Date + local selectedData = selectedTime:ToUniversalTime() :: TimeData + + local displayTime = props.Date + if chosenPage.year ~= nil then + displayTime = DateTime.fromUniversalTime(chosenPage.year, chosenPage.month) + end + + -- reconcile state when selected date changes + -- so that we show the correct page + React.useEffect(function() + local data = props.Date:ToUniversalTime() :: TimeData + setChosenPage({ + year = data.Year, + month = data.Month, + }) + return function() end + end, { props.Date }) + + local displayData = displayTime:ToUniversalTime() :: TimeData + local displayYear = displayData.Year + local displayMonth = displayData.Month + + local daysInMonth = getDaysInMonth(displayYear, displayMonth) + local lastMonthYear = if displayMonth == 1 then displayYear - 1 else displayYear + local lastMonth = if displayMonth == 1 then 12 else displayMonth - 1 + local daysInLastMonth = getDaysInMonth(lastMonthYear, lastMonth) + + local daysPrior = getDayOfWeek(displayYear, displayMonth, 1) - 1 + local daysAfter = 7 * 6 - daysInMonth - daysPrior + + -- common-year february starting on a monday (e.g. february 2027) + -- we display 7 days before, 1-28, then 7 days after + if daysPrior == 0 and daysAfter == 14 then + daysPrior = 7 + daysAfter = 7 + end + + local colorModifier = Enum.StudioStyleGuideModifier.Default + if props.Disabled then + colorModifier = Enum.StudioStyleGuideModifier.Disabled + end + + local items: { React.ReactNode } = {} + local index = 1 + for i = 1, 7 do + items[index] = React.createElement("TextLabel", { + Text = dayShortName[i], + LayoutOrder = i, + Font = Constants.DefaultFont, + TextSize = Constants.DefaultTextSize, + TextColor3 = theme:GetColor(Enum.StudioStyleGuideColor.TitlebarText, colorModifier), + BackgroundTransparency = 1, + }) + index += 1 + end + + local function makeOnActivated(day: number, month: number, year: number) + return function() + local newDate = DateTime.fromUniversalTime(year, month, day) + if props.OnChanged then + props.OnChanged(newDate) + end + end + end + + for i = 1, daysPrior do + local day = daysInLastMonth - daysPrior + i + local month = (displayMonth - 2) % 12 + 1 + local year = if displayMonth == 1 then displayYear - 1 else displayYear + + items[index] = React.createElement(DayButton, { + Selected = day == selectedData.Day and month == selectedData.Month and year == selectedData.Year, + Text = getDayNumberText(day), + LayoutOrder = index, + Fade = true, + OnActivated = makeOnActivated(day, month, year), + Disabled = props.Disabled, + }) + index += 1 + end + + for i = 1, daysInMonth do + local day = i + local month = displayMonth + local year = displayYear + + items[index] = React.createElement(DayButton, { + Selected = day == selectedData.Day and month == selectedData.Month and year == selectedData.Year, + Text = getDayNumberText(day), + LayoutOrder = index, + OnActivated = makeOnActivated(day, month, year), + Disabled = props.Disabled, + }) + index += 1 + end + + for i = 1, daysAfter do + local day = i + local month = displayMonth % 12 + 1 + local year = if displayMonth == 12 then displayYear + 1 else displayYear + + items[index] = React.createElement(DayButton, { + Selected = day == selectedData.Day and month == selectedData.Month and year == selectedData.Year, + Text = getDayNumberText(i), + LayoutOrder = index, + Fade = true, + OnActivated = makeOnActivated(day, month, year), + Disabled = props.Disabled, + }) + index += 1 + end + + return React.createElement("Frame", { + Size = props.Size or Constants.DefaultDatePickerSize, + AnchorPoint = props.AnchorPoint, + Position = props.Position, + ZIndex = props.ZIndex, + LayoutOrder = props.LayoutOrder, + }, { + Main = React.createElement("Frame", { + Size = UDim2.fromScale(1, 1), + BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.MainBackground), + BorderColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Border), + }, { + Header = React.createElement("TextLabel", { + Size = UDim2.new(1, 0, 0, TITLE_HEIGHT), + Text = displayTime:FormatUniversalTime("MMMM YYYY", LOCALE), + Font = Constants.DefaultFont, + TextSize = Constants.DefaultTextSize, + TextColor3 = theme:GetColor(Enum.StudioStyleGuideColor.MainText, colorModifier), + BackgroundColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Titlebar), + BorderColor3 = theme:GetColor(Enum.StudioStyleGuideColor.Border), + }, { + PrevMonth = React.createElement(MonthButton, { + Disabled = props.Disabled, + Position = UDim2.fromOffset(4, 6), + ImageRectOffset = Vector2.new(0, 0), + OnActivated = function() + setChosenPage({ + year = displayMonth == 1 and displayYear - 1 or displayYear, + month = displayMonth == 1 and 12 or displayMonth - 1, + }) + end, + }), + NextMonth = React.createElement(MonthButton, { + Disabled = props.Disabled, + Position = UDim2.new(1, -4, 0, 6), + AnchorPoint = Vector2.new(1, 0), + ImageRectOffset = Vector2.new(5, 0), + OnActivated = function() + setChosenPage({ + year = displayMonth == 12 and displayYear + 1 or displayYear, + month = displayMonth == 12 and 1 or displayMonth + 1, + }) + end, + }), + }), + Grid = React.createElement("Frame", { + AnchorPoint = Vector2.new(0, 1), + Position = UDim2.new(0, 3, 1, -OUTER_PAD), + Size = UDim2.new(1, -6, 1, -TITLE_HEIGHT - OUTER_PAD * 2), + BackgroundTransparency = 1, + }, { + Layout = React.createElement("UIGridLayout", { + SortOrder = Enum.SortOrder.LayoutOrder, + CellSize = UDim2.new(1 / 7, 0, 1 / 7, 0), + CellPadding = UDim2.fromOffset(0, 0), + FillDirectionMaxCells = 7, + HorizontalAlignment = Enum.HorizontalAlignment.Center, + VerticalAlignment = Enum.VerticalAlignment.Center, + }), + }, items), + }), + }) +end + +return DatePicker diff --git a/src/Components/Foundation/BaseButton.luau b/src/Components/Foundation/BaseButton.luau index 7653113..61ae677 100644 --- a/src/Components/Foundation/BaseButton.luau +++ b/src/Components/Foundation/BaseButton.luau @@ -13,7 +13,9 @@ local DEFAULT_HEIGHT = Constants.DefaultButtonHeight export type BaseButtonConsumerProps = CommonProps.T & { AutomaticSize: Enum.AutomaticSize?, OnActivated: (() -> ())?, + Selected: boolean?, Text: string?, + TextTransparency: number?, Icon: { Image: string, Size: Vector2, @@ -25,9 +27,9 @@ export type BaseButtonConsumerProps = CommonProps.T & { } export type BaseButtonProps = BaseButtonConsumerProps & { - BackgroundColorStyle: Enum.StudioStyleGuideColor, - BorderColorStyle: Enum.StudioStyleGuideColor, - TextColorStyle: Enum.StudioStyleGuideColor, + BackgroundColorStyle: Enum.StudioStyleGuideColor?, + BorderColorStyle: Enum.StudioStyleGuideColor?, + TextColorStyle: Enum.StudioStyleGuideColor?, } local function BaseButton(props: BaseButtonProps) @@ -51,15 +53,21 @@ local function BaseButton(props: BaseButtonProps) local modifier = Enum.StudioStyleGuideModifier.Default if props.Disabled then modifier = Enum.StudioStyleGuideModifier.Disabled + elseif props.Selected then + modifier = Enum.StudioStyleGuideModifier.Selected 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 backColorStyle = props.BackgroundColorStyle or Enum.StudioStyleGuideColor.Button + local borderColorStyle = props.BorderColorStyle or Enum.StudioStyleGuideColor.ButtonBorder + local textColorStyle = props.TextColorStyle or Enum.StudioStyleGuideColor.ButtonText + + local backColor = theme:GetColor(backColorStyle, modifier) + local borderColor3 = theme:GetColor(borderColorStyle, modifier) + local textColor = theme:GetColor(textColorStyle, modifier) local size = props.Size or UDim2.new(1, 0, 0, DEFAULT_HEIGHT) local autoSize = props.AutomaticSize @@ -123,6 +131,7 @@ local function BaseButton(props: BaseButtonProps) Font = Constants.DefaultFont, TextSize = Constants.DefaultTextSize, Text = props.Text, + TextTransparency = props.TextTransparency or 0, Size = UDim2.new(0, textSize.X, 1, 0), BackgroundTransparency = 1, LayoutOrder = 2, diff --git a/src/Constants.luau b/src/Constants.luau index 1805eea..6e4fddd 100644 --- a/src/Constants.luau +++ b/src/Constants.luau @@ -66,4 +66,9 @@ Constants.DefaultColorPickerSize = UDim2.fromOffset(260, 285) --- The default window size of number sequence pickers. Constants.DefaultNumberSequencePickerSize = UDim2.fromOffset(425, 285) +--- @within Constants +--- @prop DefaultDatePickerSize UDim2 +--- The default window size of date pickers. +Constants.DefaultDatePickerSize = UDim2.fromOffset(202, 160) + return table.freeze(Constants) diff --git a/src/Stories/DatePicker.story.luau b/src/Stories/DatePicker.story.luau new file mode 100644 index 0000000..74fcd78 --- /dev/null +++ b/src/Stories/DatePicker.story.luau @@ -0,0 +1,24 @@ +local React = require("@pkg/@jsdotlua/react") + +local DatePicker = require("../Components/DatePicker") +local Label = require("../Components/Label") +local createStory = require("./Helpers/createStory") + +local function Story() + local date, setDate = React.useState(DateTime.now()) + + return React.createElement(React.Fragment, {}, { + Picker = React.createElement(DatePicker, { + Date = date, + OnChanged = setDate, + LayoutOrder = 1, + }), + Display = React.createElement(Label, { + LayoutOrder = 2, + Size = UDim2.new(1, 0, 0, 20), + Text = `Selected: {date:FormatUniversalTime("LL", "en-us")}`, + }), + }) +end + +return createStory(Story) diff --git a/src/init.luau b/src/init.luau index 51f85f0..4309105 100644 --- a/src/init.luau +++ b/src/init.luau @@ -5,6 +5,7 @@ return { Button = require("./Components/Button"), Checkbox = require("./Components/Checkbox"), ColorPicker = require("./Components/ColorPicker"), + DatePicker = require("./Components/DatePicker"), Dropdown = require("./Components/Dropdown"), DropShadowFrame = require("./Components/DropShadowFrame"), Label = require("./Components/Label"),