Created
April 5, 2025 20:42
-
-
Save dmjio/a029c45b73ea00cc40c6ff075df223f7 to your computer and use it in GitHub Desktop.
Miso integration with the TailwindUI Components Library, thanks to DeepSeek R1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{-# LANGUAGE OverloadedStrings #-} | |
{-# LANGUAGE RecordWildCards #-} | |
{-# LANGUAGE TypeApplications #-} | |
module TailwindUI.Components where | |
import Miso | |
import Miso.String (MisoString, ms) | |
import qualified Data.Map as Map | |
-- ======================== | |
-- Common Types | |
-- ======================== | |
data Color = Primary | Secondary | Success | Danger | Warning | Info | Gray | |
deriving (Show, Eq, Enum, Bounded) | |
colorClass :: Color -> MisoString | |
colorClass = \case | |
Primary -> "blue" | |
Secondary -> "purple" | |
Success -> "green" | |
Danger -> "red" | |
Warning -> "yellow" | |
Info -> "sky" | |
Gray -> "gray" | |
data Size = Xs | Sm | Md | Lg | Xl | |
deriving (Show, Eq, Enum, Bounded) | |
sizeClass :: Size -> MisoString | |
sizeClass = \case | |
Xs -> "xs" | |
Sm -> "sm" | |
Md -> "md" | |
Lg -> "lg" | |
Xl -> "xl" | |
-- ======================== | |
-- Interactive Components | |
-- ======================== | |
-- Button Component | |
data Button action = Button | |
{ btnLabel :: MisoString | |
, btnColor :: Color | |
, btnSize :: Size | |
, btnDisabled :: Bool | |
, btnOnClick :: action | |
, btnIcon :: Maybe (View action) | |
} | |
button :: Button action -> View action | |
button Button{..} = | |
button_ | |
[ class_ $ ms $ "inline-flex items-center rounded-md border border-transparent bg-" | |
<> colorClass btnColor <> "-600 px-3 py-2 text-sm font-medium leading-4 text-white shadow-sm hover:bg-" | |
<> colorClass btnColor <> "-700 focus:outline-none focus:ring-2 focus:ring-" | |
<> colorClass btnColor <> "-500 focus:ring-offset-2" | |
<> if btnDisabled then " opacity-50 cursor-not-allowed" else "" | |
, onClick btnOnClick | |
, disabled_ btnDisabled | |
] | |
(maybe [] (\i -> [i]) btnIcon ++ [text btnLabel]) | |
-- Dropdown Menu | |
data DropdownItem action = DropdownItem | |
{ itemLabel :: MisoString | |
, itemAction :: action | |
, itemIcon :: Maybe (View action) | |
} | |
data Dropdown action = Dropdown | |
{ dropdownButton :: View action | |
, dropdownItems :: [DropdownItem action] | |
, dropdownAlign :: Align -- Left | Right | |
} | |
data DropdownModel = DropdownModel | |
{ isOpen :: Bool | |
} | |
data Align = Left | Right | |
dropdown :: Dropdown action -> View action | |
dropdown Dropdown{..} = | |
embed @DropdownModel $ \model -> | |
div_ [ class_ "relative inline-block text-left" ] [ | |
div_ [ onClick ToggleDropdown ] [ dropdownButton ], | |
if model.isOpen | |
then div_ | |
[ class_ $ "absolute " <> alignClass dropdownAlign <> " mt-2 w-56 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none z-10" | |
, onBlur CloseDropdown | |
] | |
(map renderItem dropdownItems) | |
else noHtml | |
] | |
where | |
initialModel = DropdownModel False | |
updateModel ToggleDropdown m = m { isOpen = not (isOpen m) } | |
updateModel CloseDropdown m = m { isOpen = False } | |
alignClass Left = "left-0" | |
alignClass Right = "right-0" | |
renderItem DropdownItem{..} = | |
button_ | |
[ class_ "flex w-full items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" | |
, onClick itemAction | |
] | |
(maybe [] (\i -> [i]) itemIcon ++ [text itemLabel]) | |
-- Modal Dialog | |
data Modal action = Modal | |
{ modalTitle :: MisoString | |
, modalContent :: View action | |
, modalActions :: [View action] | |
, modalOnClose :: action | |
} | |
data ModalModel = ModalModel | |
{ isOpen :: Bool | |
} | |
modal :: Modal action -> View action | |
modal Modal{..} = | |
embed @ModalModel $ \model -> | |
if model.isOpen | |
then div_ | |
[ class_ "relative z-10" | |
, ariaAttribute "aria-labelledby" "modal-title" | |
, role_ "dialog" | |
, ariaAttribute "aria-modal" "true" | |
] | |
[ div_ [ class_ "fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" ] [], | |
div_ [ class_ "fixed inset-0 z-10 overflow-y-auto" ] [ | |
div_ [ class_ "flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0" ] [ | |
div_ [ class_ "relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg" ] [ | |
div_ [ class_ "bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4" ] [ | |
h3_ [ class_ "text-lg font-medium leading-6 text-gray-900", id_ "modal-title" ] [ text modalTitle ], | |
div_ [ class_ "mt-2" ] [ modalContent ] | |
], | |
div_ [ class_ "bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6" ] modalActions | |
] | |
] | |
] | |
] | |
else noHtml | |
where | |
initialModel = ModalModel False | |
updateModel OpenModal m = m { isOpen = True } | |
updateModel CloseModal m = m { isOpen = False } | |
-- Tabs Component | |
data Tab action = Tab | |
{ tabName :: MisoString | |
, tabContent :: View action | |
} | |
data Tabs action = Tabs | |
{ tabsItems :: [Tab action] | |
, tabsOnChange :: Int -> action | |
} | |
data TabsModel = TabsModel | |
{ activeTab :: Int | |
} | |
tabs :: Tabs action -> View action | |
tabs Tabs{..} = | |
embed @TabsModel $ \model -> | |
div_ [] [ | |
div_ [ class_ "border-b border-gray-200" ] [ | |
nav_ [ class_ "-mb-px flex space-x-8" ] $ | |
zipWith renderTab [0..] tabsItems | |
], | |
div_ [ class_ "py-4" ] [ | |
tabContent (tabsItems !! model.activeTab) | |
] | |
] | |
where | |
initialModel = TabsModel 0 | |
updateModel (SelectTab idx) m = m { activeTab = idx } | |
renderTab idx Tab{..} = | |
button_ | |
[ class_ $ "whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium" | |
<> if idx == model.activeTab | |
then " border-indigo-500 text-indigo-600" | |
else " border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700" | |
, onClick (tabsOnChange idx) | |
] | |
[ text tabName ] | |
-- Accordion | |
data AccordionItem action = AccordionItem | |
{ accordionTitle :: MisoString | |
, accordionContent :: View action | |
} | |
data Accordion action = Accordion | |
{ accordionItems :: [AccordionItem action] | |
, accordionOnToggle :: Int -> action | |
} | |
data AccordionModel = AccordionModel | |
{ openItems :: Map.Map Int Bool | |
} | |
accordion :: Accordion action -> View action | |
accordion Accordion{..} = | |
embed @AccordionModel $ \model -> | |
div_ [ class_ "divide-y divide-gray-200" ] $ | |
zipWith (renderItem model) [0..] accordionItems | |
where | |
initialModel = AccordionModel Map.empty | |
updateModel (ToggleItem idx) m = | |
m { openItems = Map.alter (Just . maybe True not) idx (openItems m) } | |
renderItem model idx AccordionItem{..} = | |
div_ [ class_ "py-4" ] [ | |
button_ | |
[ class_ "flex w-full items-start justify-between text-left text-gray-400" | |
, onClick (accordionOnToggle idx) | |
] [ | |
span_ [ class_ "text-base font-medium text-gray-900" ] [ text accordionTitle ], | |
span_ [ class_ "ml-6 flex h-7 items-center" ] [ | |
if Map.findWithDefault False idx (openItems model) | |
then text "-" | |
else text "+" | |
] | |
], | |
if Map.findWithDefault False idx (openItems model) | |
then div_ [ class_ "mt-2 pr-12" ] [ accordionContent ] | |
else noHtml | |
] | |
-- Notification (Toast) | |
data Notification action = Notification | |
{ notifTitle :: MisoString | |
, notifMessage :: MisoString | |
, notifType :: Color | |
, notifOnDismiss :: action | |
} | |
notification :: Notification action -> View action | |
notification Notification{..} = | |
div_ [ class_ $ "rounded-md bg-" <> colorClass notifType <> "-50 p-4" ] [ | |
div_ [ class_ "flex" ] [ | |
div_ [ class_ "flex-shrink-0" ] [ | |
iconForType notifType | |
], | |
div_ [ class_ "ml-3" ] [ | |
h3_ [ class_ $ "text-sm font-medium text-" <> colorClass notifType <> "-800" ] [ text notifTitle ], | |
div_ [ class_ $ "mt-2 text-sm text-" <> colorClass notifType <> "-700" ] [ text notifMessage ], | |
div_ [ class_ "mt-4" ] [ | |
button_ | |
[ class_ $ "rounded-md bg-" <> colorClass notifType <> "-50 text-sm font-medium text-" | |
<> colorClass notifType <> "-800 hover:bg-" <> colorClass notifType <> "-100 focus:outline-none focus:ring-2 focus:ring-" | |
<> colorClass notifType <> "-500 focus:ring-offset-2" | |
, onClick notifOnDismiss | |
] | |
[ text "Dismiss" ] | |
] | |
] | |
] | |
] | |
where | |
iconForType = \case | |
Success -> svgIcon "check-circle" "text-green-400" | |
Danger -> svgIcon "x-circle" "text-red-400" | |
Warning -> svgIcon "exclamation" "text-yellow-400" | |
Info -> svgIcon "information-circle" "text-blue-400" | |
_ -> svgIcon "information-circle" "text-gray-400" | |
svgIcon iconName color = | |
svg_ [ class_ $ "h-5 w-5 " <> color, fill_ "currentColor", viewBox_ "0 0 20 20" ] [ | |
path_ [ fillRule_ "evenodd", d_ iconPaths Map.! iconName, clipRule_ "evenodd" ] [] | |
] | |
iconPaths = Map.fromList [ | |
("check-circle", "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"), | |
("x-circle", "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"), | |
("exclamation", "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"), | |
("information-circle", "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2h-1V9z") | |
] | |
-- ======================== | |
-- Form Components | |
-- ======================== | |
-- Text Input | |
data TextInput action = TextInput | |
{ inputId :: MisoString | |
, inputLabel :: MisoString | |
, inputValue :: MisoString | |
, inputOnChange :: MisoString -> action | |
, inputPlaceholder :: Maybe MisoString | |
, inputDisabled :: Bool | |
} | |
textInput :: TextInput action -> View action | |
textInput TextInput{..} = | |
div_ [] [ | |
label_ [ for_ inputId, class_ "block text-sm font-medium text-gray-700" ] [ text inputLabel ], | |
div_ [ class_ "mt-1" ] [ | |
input_ | |
[ type_ "text" | |
, id_ inputId | |
, name_ inputId | |
, class_ "block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" | |
, value_ inputValue | |
, onInput inputOnChange | |
, maybe noHtml placeholder_ inputPlaceholder | |
, disabled_ inputDisabled | |
] | |
] | |
] | |
-- Checkbox | |
data Checkbox action = Checkbox | |
{ checkboxId :: MisoString | |
, checkboxLabel :: MisoString | |
, checkboxChecked :: Bool | |
, checkboxOnChange :: Bool -> action | |
} | |
checkbox :: Checkbox action -> View action | |
checkbox Checkbox{..} = | |
div_ [ class_ "flex items-center" ] [ | |
input_ | |
[ id_ checkboxId | |
, name_ checkboxId | |
, type_ "checkbox" | |
, class_ "h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" | |
, checked_ checkboxChecked | |
, onChange (\_ -> checkboxOnChange (not checkboxChecked)) | |
], | |
label_ [ for_ checkboxId, class_ "ml-2 block text-sm text-gray-900" ] [ text checkboxLabel ] | |
] | |
-- Radio Group | |
data RadioOption = RadioOption | |
{ optionId :: MisoString | |
, optionLabel :: MisoString | |
, optionDescription :: Maybe MisoString | |
} | |
data RadioGroup action = RadioGroup | |
{ radioName :: MisoString | |
, radioOptions :: [RadioOption] | |
, radioSelected :: Maybe MisoString | |
, radioOnChange :: MisoString -> action | |
} | |
radioGroup :: RadioGroup action -> View action | |
radioGroup RadioGroup{..} = | |
fieldset_ [] [ | |
legend_ [ class_ "sr-only" ] [ text "Notification method" ], | |
div_ [ class_ "space-y-4" ] $ | |
map renderOption radioOptions | |
] | |
where | |
renderOption RadioOption{..} = | |
div_ [ class_ "flex items-center" ] [ | |
input_ | |
[ id_ optionId | |
, name_ radioName | |
, type_ "radio" | |
, class_ "h-4 w-4 border-gray-300 text-indigo-600 focus:ring-indigo-500" | |
, checked_ (Just optionId == radioSelected) | |
, onChange (\_ -> radioOnChange optionId) | |
], | |
div_ [ class_ "ml-3" ] [ | |
label_ [ for_ optionId, class_ "block text-sm font-medium text-gray-700" ] [ text optionLabel ], | |
maybe noHtml (\d -> p_ [ class_ "text-sm text-gray-500" ] [ text d ]) optionDescription | |
] | |
] | |
-- ======================== | |
-- Example Application | |
-- ======================== | |
data AppAction | |
= ButtonClicked | |
| DropdownItemSelected MisoString | |
| ToggleDropdown | |
| CloseDropdown | |
| OpenModal | |
| CloseModal | |
| TabSelected Int | |
| AccordionToggled Int | |
| DismissNotification | |
| InputChanged MisoString | |
| CheckboxToggled Bool | |
| RadioSelected MisoString | |
| NoOp | |
data AppModel = AppModel | |
{ buttonClickCount :: Int | |
, dropdownOpen :: Bool | |
, modalOpen :: Bool | |
, activeTab :: Int | |
, openAccordionItems :: Map.Map Int Bool | |
, showNotification :: Bool | |
, inputValue :: MisoString | |
, checkboxChecked :: Bool | |
, selectedRadio :: Maybe MisoString | |
} | |
app :: App AppModel AppAction | |
app = App | |
{ initialAction = NoOp | |
, model = AppModel | |
{ buttonClickCount = 0 | |
, dropdownOpen = False | |
, modalOpen = False | |
, activeTab = 0 | |
, openAccordionItems = Map.empty | |
, showNotification = False | |
, inputValue = "" | |
, checkboxChecked = False | |
, selectedRadio = Nothing | |
} | |
, update = \action model -> case action of | |
ButtonClicked -> noEff $ model { buttonClickCount = model.buttonClickCount + 1 } | |
DropdownItemSelected item -> noEff $ model { dropdownOpen = False } | |
ToggleDropdown -> noEff $ model { dropdownOpen = not model.dropdownOpen } | |
CloseDropdown -> noEff $ model { dropdownOpen = False } | |
OpenModal -> noEff $ model { modalOpen = True } | |
CloseModal -> noEff $ model { modalOpen = False } | |
TabSelected idx -> noEff $ model { activeTab = idx } | |
AccordionToggled idx -> noEff $ model | |
{ openAccordionItems = Map.alter (Just . maybe True not) idx model.openAccordionItems } | |
DismissNotification -> noEff $ model { showNotification = False } | |
InputChanged val -> noEff $ model { inputValue = val } | |
CheckboxToggled checked -> noEff $ model { checkboxChecked = checked } | |
RadioSelected val -> noEff $ model { selectedRadio = Just val } | |
NoOp -> noEff model | |
, view = \model -> | |
div_ [ class_ "p-8 max-w-4xl mx-auto" ] [ | |
h1_ [ class_ "text-2xl font-bold mb-6" ] [ text "Tailwind UI Components in Miso" ], | |
-- Button example | |
section_ "Buttons" [ | |
button Button | |
{ btnLabel = "Primary Button" | |
, btnColor = Primary | |
, btnSize = Md | |
, btnDisabled = False | |
, btnOnClick = ButtonClicked | |
, btnIcon = Nothing | |
}, | |
p_ [ class_ "mt-2" ] [ text $ "Clicked " <> ms (show model.buttonClickCount) <> " times" ] | |
], | |
-- Dropdown example | |
section_ "Dropdown" [ | |
dropdown Dropdown | |
{ dropdownButton = button Button | |
{ btnLabel = "Options" | |
, btnColor = Gray | |
, btnSize = Md | |
, btnDisabled = False | |
, btnOnClick = ToggleDropdown | |
, btnIcon = Just (text "▼") | |
} | |
, dropdownItems = | |
[ DropdownItem "Item 1" (DropdownItemSelected "item1") Nothing | |
, DropdownItem "Item 2" (DropdownItemSelected "item2") Nothing | |
] | |
, dropdownAlign = Right | |
} | |
], | |
-- Modal example | |
section_ "Modal" [ | |
button Button | |
{ btnLabel = "Open Modal" | |
, btnColor = Secondary | |
, btnSize = Md | |
, btnDisabled = False | |
, btnOnClick = OpenModal | |
, btnIcon = Nothing | |
}, | |
modal Modal | |
{ modalTitle = "Example Modal" | |
, modalContent = p_ [] [ text "This is modal content" ] | |
, modalActions = | |
[ button Button | |
{ btnLabel = "Cancel" | |
, btnColor = Gray | |
, btnSize = Md | |
, btnDisabled = False | |
, btnOnClick = CloseModal | |
, btnIcon = Nothing | |
} | |
] | |
, modalOnClose = CloseModal | |
} | |
], | |
-- Tabs example | |
section_ "Tabs" [ | |
tabs Tabs | |
{ tabsItems = | |
[ Tab "Tab 1" (p_ [] [ text "Content for tab 1" ]) | |
, Tab "Tab 2" (p_ [] [ text "Content for tab 2" ]) | |
] | |
, tabsOnChange = TabSelected | |
} | |
], | |
-- Accordion example | |
section_ "Accordion" [ | |
accordion Accordion | |
{ accordionItems = | |
[ AccordionItem "Section 1" (p_ [] [ text "Content for section 1" ]) | |
, AccordionItem "Section 2" (p_ [] [ text "Content for section 2" ]) | |
] | |
, accordionOnToggle = AccordionToggled | |
} | |
], | |
-- Notification example | |
section_ "Notifications" [ | |
button Button | |
{ btnLabel = "Show Notification" | |
, btnColor = Success | |
, btnSize = Md | |
, btnDisabled = False | |
, btnOnClick = ButtonClicked -- Would set showNotification to True in real app | |
, btnIcon = Nothing | |
}, | |
if model.showNotification | |
then notification Notification | |
{ notifTitle = "Success" | |
, notifMessage = "Your action was completed successfully" | |
, notifType = Success | |
, notifOnDismiss = DismissNotification | |
} | |
else noHtml | |
], | |
-- Form components | |
section_ "Form Elements" [ | |
textInput TextInput | |
{ inputId = "name" | |
, inputLabel = "Name" | |
, inputValue = model.inputValue | |
, inputOnChange = InputChanged | |
, inputPlaceholder = Just "Enter your name" | |
, inputDisabled = False | |
}, | |
checkbox Checkbox | |
{ checkboxId = "subscribe" | |
, checkboxLabel = "Subscribe to newsletter" | |
, checkboxChecked = model.checkboxChecked | |
, checkboxOnChange = CheckboxToggled | |
}, | |
radioGroup RadioGroup | |
{ radioName = "notification-method" | |
, radioOptions = | |
[ RadioOption "email" "Email" (Just "Send to my email address") | |
, RadioOption "sms" "SMS" (Just "Send to my phone") | |
] | |
, radioSelected = model.selectedRadio | |
, radioOnChange = RadioSelected | |
} | |
] | |
] | |
, subs = [] | |
, events = defaultEvents | |
, mountPoint = Nothing | |
} | |
where | |
section_ title content = | |
div_ [ class_ "mb-12" ] [ | |
h2_ [ class_ "text-xl font-semibold mb-4 border-b pb-2" ] [ text title ], | |
div_ [ class_ "space-y-6" ] content | |
] |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment