Skip to content

Instantly share code, notes, and snippets.

@dmjio
Created April 5, 2025 20:42
Show Gist options
  • Save dmjio/a029c45b73ea00cc40c6ff075df223f7 to your computer and use it in GitHub Desktop.
Save dmjio/a029c45b73ea00cc40c6ff075df223f7 to your computer and use it in GitHub Desktop.
Miso integration with the TailwindUI Components Library, thanks to DeepSeek R1
{-# 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