diff --git a/source/Accordion.mint b/source/Accordion.mint
new file mode 100644
index 0000000..7e811a1
--- /dev/null
+++ b/source/Accordion.mint
@@ -0,0 +1,134 @@
+/* An accordion component with collapsible content sections. */
+component Ui.Accordion {
+ /* The size of the accordion. */
+ property size : Ui.Size = Ui.Size.Inherit
+
+ /* The list of sections. Each section is a tuple of (key, title, content). */
+ property sections : Array(Tuple(String, String, Html)) = []
+
+ /* Whether or not multiple sections can be open at the same time. */
+ property multiple : Bool = false
+
+ /* Whether or not the accordion is disabled. */
+ property disabled : Bool = false
+
+ /* The set of currently open section keys. */
+ state openSections : Set(String) = Set.empty()
+
+ /* Styles for the accordion container. */
+ style base {
+ font-size: #{Ui.Size.toString(size)};
+ font-family: var(--font-family);
+
+ border: 0.0625em solid var(--content-border);
+ border-radius: 0.375em;
+ overflow: hidden;
+
+ if disabled {
+ filter: saturate(0) brightness(0.8) contrast(0.5);
+ pointer-events: none;
+ }
+ }
+
+ /* Styles for a section. */
+ style section {
+ &:not(:last-child) {
+ border-bottom: 0.0625em solid var(--content-border);
+ }
+ }
+
+ /* Styles for a section header. */
+ style header {
+ -webkit-appearance: none;
+ appearance: none;
+ background: var(--content-color);
+ color: var(--content-text);
+ font-family: var(--font-family);
+ font-size: 1em;
+ font-weight: bold;
+
+ display: grid;
+ grid-template-columns: 1fr auto;
+ align-items: center;
+ grid-gap: 0.5em;
+
+ padding: 0.875em 1em;
+ cursor: pointer;
+ outline: none;
+ width: 100%;
+ border: 0;
+
+ line-height: 1.4;
+ text-align: left;
+
+ &:hover {
+ background: var(--content-faded-color);
+ }
+
+ &:focus-visible {
+ background: var(--content-faded-color);
+ }
+ }
+
+ /* Styles for the chevron icon. */
+ style chevron (open : Bool) {
+ transition: transform 200ms ease;
+ display: grid;
+
+ if open {
+ transform: rotate(180deg);
+ }
+ }
+
+ /* Styles for the section content. */
+ style content (open : Bool) {
+ background: var(--content-color);
+ color: var(--content-text);
+ overflow: hidden;
+
+ if open {
+ padding: 0 1em 0.875em 1em;
+ } else {
+ padding: 0;
+ height: 0;
+ }
+ }
+
+ /* Toggles a section open or closed. */
+ fun toggleSection (key : String) : Promise(Void) {
+ if Set.has(openSections, key) {
+ next { openSections: Set.delete(openSections, key) }
+ } else if multiple {
+ next { openSections: Set.add(openSections, key) }
+ } else {
+ next { openSections: Set.add(Set.empty(), key) }
+ }
+ }
+
+ /* Renders the accordion. */
+ fun render : Html {
+
+ {
+ for section of sections {
+ let {key, title, content} =
+ section
+
+ let open =
+ Set.has(openSections, key)
+
+
+
+ title
+
+
+
+
+
+
+ content
+
+ }
+ }
+
+ }
+}
diff --git a/source/Alert.mint b/source/Alert.mint
new file mode 100644
index 0000000..e09b4a8
--- /dev/null
+++ b/source/Alert.mint
@@ -0,0 +1,154 @@
+/* A status message component for displaying alerts. */
+component Ui.Alert {
+ /* The close event handler. If provided, a close button is shown. */
+ property onClose : Function(Promise(Void)) = Promise.never
+
+ /* The severity level of the alert. */
+ property level : Ui.Alert.Level = Ui.Alert.Level.Info
+
+ /* The size of the alert. */
+ property size : Ui.Size = Ui.Size.Inherit
+
+ /* The title of the alert. */
+ property title : String = ""
+
+ /* The message body of the alert. */
+ property message : String = ""
+
+ /* Whether or not the close button is shown. */
+ property closeable : Bool = false
+
+ /* Styles for the alert container. */
+ style base {
+ font-size: #{Ui.Size.toString(size)};
+ font-family: var(--font-family);
+
+ border-radius: 0.375em;
+ padding: 0.75em 1em;
+
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ align-items: start;
+ grid-gap: 0.625em;
+
+ case level {
+ Ui.Alert.Level.Info =>
+ {
+ background: var(--primary-light-color);
+ color: var(--primary-light-text);
+ border: 0.0625em solid var(--primary-color);
+ }
+
+ Ui.Alert.Level.Success =>
+ {
+ background: var(--success-light-color);
+ color: var(--success-light-text);
+ border: 0.0625em solid var(--success-color);
+ }
+
+ Ui.Alert.Level.Warning =>
+ {
+ background: var(--warning-light-color);
+ color: var(--warning-light-text);
+ border: 0.0625em solid var(--warning-color);
+ }
+
+ Ui.Alert.Level.Danger =>
+ {
+ background: var(--danger-light-color);
+ color: var(--danger-light-text);
+ border: 0.0625em solid var(--danger-color);
+ }
+ }
+ }
+
+ /* Styles for the icon. */
+ style icon {
+ display: grid;
+ align-items: center;
+ font-size: 1.25em;
+ line-height: 1;
+ margin-top: 0.1em;
+ }
+
+ /* Styles for the content area. */
+ style content {
+ display: grid;
+ grid-gap: 0.25em;
+ }
+
+ /* Styles for the title. */
+ style title {
+ font-weight: bold;
+ line-height: 1.4;
+ }
+
+ /* Styles for the message. */
+ style message {
+ line-height: 1.4;
+ font-size: 0.9375em;
+ }
+
+ /* Styles for the close button. */
+ style close {
+ -webkit-appearance: none;
+ appearance: none;
+ background: none;
+ color: inherit;
+ cursor: pointer;
+ outline: none;
+ padding: 0;
+ border: 0;
+ margin: 0;
+ opacity: 0.6;
+
+ display: grid;
+ align-items: center;
+ font-size: 1em;
+ margin-top: 0.1em;
+
+ &:hover {
+ opacity: 1;
+ }
+ }
+
+ /* Returns the appropriate icon for the alert level. */
+ get levelIcon : Html {
+ case level {
+ Ui.Alert.Level.Info => Ui.Icons.INFO
+ Ui.Alert.Level.Success => Ui.Icons.CHECK
+ Ui.Alert.Level.Warning => Ui.Icons.ALERT
+ Ui.Alert.Level.Danger => Ui.Icons.ALERT
+ }
+ }
+
+ /* Handles the close button click. */
+ fun handleClose (event : Html.Event) : Promise(Void) {
+ onClose()
+ }
+
+ /* Renders the alert. */
+ fun render : Html {
+
+
+
+
+
+
+ if String.isNotBlank(title) {
+ title
+ }
+
+ if String.isNotBlank(message) {
+ message
+ }
+
+
+ if closeable {
+
+
+
+ }
+
+ }
+}
diff --git a/source/Avatar.mint b/source/Avatar.mint
new file mode 100644
index 0000000..7c9e8ef
--- /dev/null
+++ b/source/Avatar.mint
@@ -0,0 +1,78 @@
+/* A user avatar component with image and initials fallback. */
+component Ui.Avatar {
+ /* The size of the avatar. */
+ property size : Ui.Size = Ui.Size.Inherit
+
+ /* The image source URL. */
+ property src : String = ""
+
+ /* The alt text for the image. */
+ property alt : String = ""
+
+ /* The initials to show as fallback when there is no image. */
+ property initials : String = ""
+
+ /* Whether or not the avatar has a circular shape. */
+ property circular : Bool = true
+
+ /* Styles for the avatar container. */
+ style base {
+ font-size: #{Ui.Size.toString(size)};
+ font-family: var(--font-family);
+
+ background: var(--primary-color);
+ color: var(--primary-text);
+
+ justify-content: center;
+ display: inline-flex;
+ align-items: center;
+
+ overflow: hidden;
+ height: 2.5em;
+ width: 2.5em;
+
+ if circular {
+ border-radius: 50%;
+ } else {
+ border-radius: 0.375em;
+ }
+ }
+
+ /* Styles for the image. */
+ style image {
+ object-fit: cover;
+ height: 100%;
+ width: 100%;
+ }
+
+ /* Styles for the fallback initials. */
+ style fallback {
+ text-transform: uppercase;
+ font-weight: bold;
+ font-size: 1em;
+ line-height: 1;
+
+ user-select: none;
+ }
+
+ /* Styles for the default icon fallback. */
+ style icon {
+ font-size: 1.25em;
+ display: grid;
+ }
+
+ /* Renders the avatar. */
+ fun render : Html {
+
+ if String.isNotBlank(src) {
+
+ } else if String.isNotBlank(initials) {
+ initials
+ } else {
+
+
+
+ }
+
+ }
+}
diff --git a/source/Badge.mint b/source/Badge.mint
new file mode 100644
index 0000000..33fd329
--- /dev/null
+++ b/source/Badge.mint
@@ -0,0 +1,41 @@
+/* A small status label for displaying tags, counts, or statuses. */
+component Ui.Badge {
+ /* The size of the badge. */
+ property size : Ui.Size = Ui.Size.Inherit
+
+ /* The label to display. */
+ property label : String = ""
+
+ /* The color of the badge (CSS color value). */
+ property color : String = "var(--primary-color)"
+
+ /* The text color of the badge (CSS color value). */
+ property textColor : String = "var(--primary-text)"
+
+ /* Styles for the badge. */
+ style base {
+ font-size: #{Ui.Size.toString(size)};
+ font-family: var(--font-family);
+
+ background: #{color};
+ color: #{textColor};
+
+ border-radius: 1em;
+ padding: 0.125em 0.625em;
+
+ display: inline-flex;
+ align-items: center;
+
+ line-height: 1.5em;
+ font-weight: bold;
+ font-size: 0.75em;
+
+ white-space: nowrap;
+ user-select: none;
+ }
+
+ /* Renders the badge. */
+ fun render : Html {
+ label
+ }
+}
diff --git a/source/DateTimePicker.mint b/source/DateTimePicker.mint
new file mode 100644
index 0000000..77facc3
--- /dev/null
+++ b/source/DateTimePicker.mint
@@ -0,0 +1,331 @@
+/* A combined date and time picker component. */
+component Ui.DateTimePicker {
+ /* The change event handler. */
+ property onChange : Function(Time, Promise(Void)) = Promise.never1
+
+ /* The language to use for time formatting. */
+ property language : Time.Format.Language = Time.Format.ENGLISH
+
+ /* The position of the dropdown. */
+ property position : Ui.Position = Ui.Position.BottomRight
+
+ /* The size of the picker. */
+ property size : Ui.Size = Ui.Size.Inherit
+
+ /* The current value (as `Time`). */
+ property value : Time = Time.now()
+
+ /* Whether or not the picker is disabled. */
+ property disabled : Bool = false
+
+ /* Whether or not the picker is invalid. */
+ property invalid : Bool = false
+
+ /* The offset of the dropdown from the input. */
+ property offset : Number = 5
+
+ /* The formatter for the time in the input. */
+ property formatter = Time.format
+
+ /* The format for the date and time in the input. */
+ property format : String = "%Y-%m-%d %H:%M"
+
+ /* The step in minutes for the minute selector. */
+ property step : Number = 1
+
+ /* Whether or not to show the timezone in the label. */
+ property showTimezone : Bool = false
+
+ /* A variable for tracking the current month. */
+ state month : Maybe(Time) = Maybe.Nothing
+
+ style label {
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+ }
+
+ /* Styles for the panel container. */
+ style panel {
+ font-family: var(--font-family);
+ display: grid;
+ grid-gap: 0;
+ }
+
+ /* Styles for the separator between date and time sections. */
+ style separator {
+ border-top: 0.0625em solid var(--input-border);
+ }
+
+ /* Styles for the time section. */
+ style timeSection {
+ grid-template-columns: 1fr 1fr;
+ grid-gap: 0.25em;
+ display: grid;
+ padding: 0.5em;
+ }
+
+ /* Styles for a column of time values. */
+ style column {
+ scrollbar-width: thin;
+ overflow-y: auto;
+ max-height: 10em;
+ }
+
+ /* Styles for the column header. */
+ style columnHeader {
+ text-transform: uppercase;
+ text-align: center;
+ font-weight: bold;
+ font-size: 0.74em;
+ padding: 0.5em 0;
+ opacity: 0.5;
+ }
+
+ /* Styles for a time cell. */
+ style cell (selected : Bool) {
+ border-radius: 0.25em;
+ text-align: center;
+ line-height: 2em;
+ cursor: pointer;
+
+ if selected {
+ background: var(--primary-color);
+ color: var(--primary-text);
+ }
+
+ &:hover {
+ background: var(--primary-color);
+ color: var(--primary-text);
+ }
+ }
+
+ /* Styles for the timezone display. */
+ style timezone {
+ font-size: 0.8em;
+ opacity: 0.6;
+ padding: 0.25em 0.5em;
+ text-align: center;
+ border-top: 0.0625em solid var(--input-border);
+ }
+
+ /* Returns the current UTC hour from the value. */
+ get currentHour : Number {
+ `#{value}.getUTCHours()`
+ }
+
+ /* Returns the current UTC minute from the value. */
+ get currentMinute : Number {
+ `#{value}.getUTCMinutes()`
+ }
+
+ /* Returns the timezone string of the current value. */
+ get timezoneString : String {
+ `(() => {
+ try {
+ return Intl.DateTimeFormat().resolvedOptions().timeZone
+ + " (UTC" + (#{value}.getTimezoneOffset() <= 0 ? "+" : "-")
+ + String(Math.abs(Math.floor(#{value}.getTimezoneOffset() / 60))).padStart(2, "0")
+ + ":" + String(Math.abs(#{value}.getTimezoneOffset() % 60)).padStart(2, "0")
+ + ")";
+ } catch(e) {
+ return "UTC" + (#{value}.getTimezoneOffset() <= 0 ? "+" : "-")
+ + String(Math.abs(Math.floor(#{value}.getTimezoneOffset() / 60))).padStart(2, "0")
+ + ":" + String(Math.abs(#{value}.getTimezoneOffset() % 60)).padStart(2, "0");
+ }
+ })()`
+ }
+
+ /* Handles the date change from the calendar, preserving the current UTC time. */
+ fun handleDateChange (day : Time) : Promise(Void) {
+ onChange(
+ `(() => {
+ var d = new Date(Date.UTC(
+ #{day}.getUTCFullYear(), #{day}.getUTCMonth(), #{day}.getUTCDate(),
+ #{value}.getUTCHours(), #{value}.getUTCMinutes(), #{value}.getUTCSeconds()));
+ return d;
+ })()`)
+ }
+
+ /* Handles clicking on an hour cell, reading the value from the data attribute. */
+ fun handleHourSelect (event : Html.Event) : Promise(Void) {
+ let hour =
+ event.target
+ |> Dom.getAttribute("data-value")
+ |> Maybe.withDefault("0")
+ |> Number.fromString()
+ |> Maybe.withDefault(0)
+
+ onChange(
+ `(() => {
+ var d = new Date(#{value}.getTime());
+ d.setUTCHours(#{hour});
+ return d;
+ })()`)
+ }
+
+ /* Handles clicking on a minute cell, reading the value from the data attribute. */
+ fun handleMinuteSelect (event : Html.Event) : Promise(Void) {
+ let minute =
+ event.target
+ |> Dom.getAttribute("data-value")
+ |> Maybe.withDefault("0")
+ |> Number.fromString()
+ |> Maybe.withDefault(0)
+
+ onChange(
+ `(() => {
+ var d = new Date(#{value}.getTime());
+ d.setUTCMinutes(#{minute});
+ return d;
+ })()`)
+ }
+
+ /* Handles the month change event. */
+ fun handleMonthChange (value : Time) {
+ next { month: Maybe.Just(value) }
+ }
+
+ /* Handles the keydown event. */
+ fun handleKeyDown (event : Html.Event) {
+ case event.keyCode {
+ 37 =>
+ onChange(
+ `(() => {
+ var d = new Date(#{value}.getTime());
+ d.setUTCDate(d.getUTCDate() - 1);
+ return d;
+ })()`)
+
+ 38 =>
+ {
+ Html.Event.preventDefault(event)
+
+ onChange(
+ `(() => {
+ var d = new Date(#{value}.getTime());
+ d.setUTCDate(d.getUTCDate() - 1);
+ return d;
+ })()`)
+ }
+
+ 39 =>
+ onChange(
+ `(() => {
+ var d = new Date(#{value}.getTime());
+ d.setUTCDate(d.getUTCDate() + 1);
+ return d;
+ })()`)
+
+ 40 =>
+ {
+ Html.Event.preventDefault(event)
+
+ onChange(
+ `(() => {
+ var d = new Date(#{value}.getTime());
+ d.setUTCDate(d.getUTCDate() + 1);
+ return d;
+ })()`)
+ }
+
+ => next { }
+ }
+ }
+
+ /* Renders the date time picker. */
+ fun render : Html {
+ let hours =
+ Array.range(0, 23)
+
+ let minutes =
+ Array.range(0, 59)
+ |> Array.select(
+ (m : Number) : Bool { `#{m} % #{step} === 0` })
+
+ let panel =
+
+
+
+
+
+
+
+
+
+
+ {
+ for hour of hours {
+
+
+ Number.toString(hour)
+ |> String.padStart("0", 2)
+
+ }
+ }
+
+
+
+
+
+
+ {
+ for minute of minutes {
+
+
+ Number.toString(minute)
+ |> String.padStart("0", 2)
+
+ }
+ }
+
+
+
+
+ if showTimezone {
+ timezoneString
+ }
+
+
+
+ let formattedLabel =
+ formatter(value, language, format)
+
+ let labelText =
+ if showTimezone {
+ formattedLabel + " " + timezoneString
+ } else {
+ formattedLabel
+ }
+
+ let label =
+ Maybe.Just(labelText)
+
+
+ }
+}
diff --git a/source/Enums/Alert.Level.mint b/source/Enums/Alert.Level.mint
new file mode 100644
index 0000000..6a9f58c
--- /dev/null
+++ b/source/Enums/Alert.Level.mint
@@ -0,0 +1,14 @@
+/* Represents the severity level of an alert. */
+type Ui.Alert.Level {
+ /* Informational alert. */
+ Info
+
+ /* Success alert. */
+ Success
+
+ /* Warning alert. */
+ Warning
+
+ /* Danger/error alert. */
+ Danger
+}
diff --git a/source/Main.mint b/source/Main.mint
new file mode 100644
index 0000000..36cf081
--- /dev/null
+++ b/source/Main.mint
@@ -0,0 +1,754 @@
+/* Temporary testbed page showcasing all components. */
+component Main {
+ /* Whether the alert is visible. */
+ state alertVisible : Bool = true
+
+ /* The selected radio value. */
+ state radioValue : String = "a"
+
+ /* The selected date for the date picker. */
+ state dateValue : Time = Time.today()
+
+ /* The selected time for the time picker. */
+ state timeValue : Time = Time.now()
+
+ /* The selected datetime for the datetime picker. */
+ state datetimeValue : Time = Time.now()
+
+ /* The input value. */
+ state inputValue : String = ""
+
+ /* The textarea value. */
+ state textareaValue : String = ""
+
+ /* The checkbox value. */
+ state checkboxValue : Bool = false
+
+ /* The toggle value. */
+ state toggleValue : Bool = false
+
+ /* The slider value. */
+ state sliderValue : Number = 50
+
+ /* The select value. */
+ state selectValue : String = ""
+
+ /* The native select value. */
+ state nativeSelectValue : String = ""
+
+ /* The calendar day. */
+ state calendarDay : Time = Time.today()
+
+ /* The calendar month. */
+ state calendarMonth : Time = Time.today()
+
+ /* The active tab key. */
+ state activeTab : String = "tab1"
+
+ /* Style for the page. */
+ style page {
+ max-width: 960px;
+ margin: 0 auto;
+ padding: 2em;
+ }
+
+ /* Style for a section. */
+ style section {
+ margin-bottom: 3em;
+ }
+
+ /* Style for a section title. */
+ style sectionTitle {
+ font-family: var(--font-family);
+ color: var(--title-color);
+ font-weight: bold;
+ font-size: 1.5em;
+ line-height: 1.2;
+
+ border-bottom: 0.0625em solid var(--title-border);
+ padding-bottom: 0.5em;
+ margin-bottom: 1em;
+ }
+
+ /* Style for a sub-section title. */
+ style subTitle {
+ font-family: var(--font-family);
+ color: var(--content-text);
+ font-weight: bold;
+ font-size: 1em;
+
+ margin-bottom: 0.5em;
+ margin-top: 1em;
+ }
+
+ /* Style for a row of items. */
+ style row {
+ align-items: center;
+ flex-wrap: wrap;
+ display: flex;
+ gap: 1em;
+ }
+
+ /* Style for a column of items. */
+ style column {
+ display: grid;
+ grid-gap: 1em;
+ }
+
+ /* Style for a demo box with a max width. */
+ style box {
+ max-width: 400px;
+ width: 100%;
+ }
+
+ /* Page title style. */
+ style pageTitle {
+ font-family: var(--font-family);
+ color: var(--title-color);
+ font-weight: bold;
+ font-size: 2em;
+ line-height: 1.2;
+ margin-bottom: 0.5em;
+ }
+
+ /* Page description style. */
+ style pageDescription {
+ font-family: var(--font-family);
+ color: var(--content-text);
+ margin-bottom: 2em;
+ line-height: 1.6;
+ }
+
+ fun render : Html {
+
+
+ "Mint UI Component Showcase"
+
+
+ "A testbed page demonstrating all available components."
+
+
+
+
+
+
+ /* -------------------------------------------------------- */
+ /* BUTTONS */
+ /* -------------------------------------------------------- */
+
+ "Button"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /* -------------------------------------------------------- */
+ /* INPUT */
+ /* -------------------------------------------------------- */
+
+ "Input"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /* -------------------------------------------------------- */
+ /* TEXTAREA */
+ /* -------------------------------------------------------- */
+
+ "Textarea"
+
+
+
+
+
+
+ /* -------------------------------------------------------- */
+ /* CHECKBOX */
+ /* -------------------------------------------------------- */
+
+ "Checkbox"
+
+
+
+
+
+
+
+
+ /* -------------------------------------------------------- */
+ /* TOGGLE (SWITCH) */
+ /* -------------------------------------------------------- */
+
+ "Toggle"
+
+
+
+
+
+
+
+
+ /* -------------------------------------------------------- */
+ /* SLIDER */
+ /* -------------------------------------------------------- */
+
+ "Slider"
+
+
+
+
+
+
+ /* -------------------------------------------------------- */
+ /* SELECT */
+ /* -------------------------------------------------------- */
+
+ "Select"
+
+
+
+ "Apple">,
+ key: "apple"),
+ Ui.ListItem.Item(
+ matchString: "Banana",
+ content: <>"Banana">,
+ key: "banana"),
+ Ui.ListItem.Item(
+ matchString: "Cherry",
+ content: <>"Cherry">,
+ key: "cherry")
+ ]}
+ onChange={(v : String) { next { selectValue: v } }}
+ placeholder="Choose a fruit..."
+ value={selectValue}/>
+
+ "Red">,
+ key: "red"),
+ Ui.ListItem.Item(
+ matchString: "Green",
+ content: <>"Green">,
+ key: "green"),
+ Ui.ListItem.Item(
+ matchString: "Blue",
+ content: <>"Blue">,
+ key: "blue")
+ ]}
+ onChange={(v : String) { next { nativeSelectValue: v } }}
+ placeholder="Native select..."
+ value={nativeSelectValue}/>
+
+
+
+
+ /* -------------------------------------------------------- */
+ /* CALENDAR */
+ /* -------------------------------------------------------- */
+
+ "Calendar"
+
+
+
+
+ /* -------------------------------------------------------- */
+ /* DATE PICKER */
+ /* -------------------------------------------------------- */
+
+ "DatePicker"
+
+
+
+
+
+
+
+
+
+
+ /* -------------------------------------------------------- */
+ /* TIME PICKER */
+ /* -------------------------------------------------------- */
+
+ "TimePicker"
+
+
+
+
+
+
+
+ "With 15-minute steps"
+
+
+
+
+
+
+ /* -------------------------------------------------------- */
+ /* DATETIME PICKER */
+ /* -------------------------------------------------------- */
+
+ "DateTimePicker"
+
+
+
+
+
+
+
+
+
+
+ /* -------------------------------------------------------- */
+ /* FIELD */
+ /* -------------------------------------------------------- */
+
+ "Field"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /* -------------------------------------------------------- */
+ /* BADGE */
+ /* -------------------------------------------------------- */
+
+ "Badge"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /* -------------------------------------------------------- */
+ /* AVATAR */
+ /* -------------------------------------------------------- */
+
+ "Avatar"
+
+
+
+
+
+
+
+
+ /* -------------------------------------------------------- */
+ /* TOOLTIP */
+ /* -------------------------------------------------------- */
+
+ "Tooltip"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /* -------------------------------------------------------- */
+ /* ALERT */
+ /* -------------------------------------------------------- */
+
+ "Alert"
+
+
+
+
+
+
+
+
+
+
+
+
+ /* -------------------------------------------------------- */
+ /* RADIO GROUP */
+ /* -------------------------------------------------------- */
+
+ "RadioGroup"
+
+
+ "Vertical"
+
+
+
+ "Horizontal"
+
+
+
+ "Disabled"
+
+
+
+
+
+ /* -------------------------------------------------------- */
+ /* ACCORDION */
+ /* -------------------------------------------------------- */
+
+ "Accordion"
+
+
+ "Single mode"
+
+ "A comprehensive component library for the Mint programming language."
},
+ {"s2", "Is it themable?", "Yes! It supports both light and dark modes with CSS custom properties."
},
+ {"s3", "How do I install it?", "Add it to your mint.json dependencies and run mint install."
}
+ ]}/>
+
+ "Multiple mode"
+
+ "Content for section one."},
+ {"m2", "Section Two", "Content for section two."
}
+ ]}/>
+
+
+
+ /* -------------------------------------------------------- */
+ /* SEPARATOR */
+ /* -------------------------------------------------------- */
+
+ "Separator"
+
+
+
+ "Content above the separator."
+
+
+
+
+
+ "Content below the separator."
+
+
+
+ "Vertical"
+
+
+ "Left"
+
+ "Right"
+
+
+
+ /* -------------------------------------------------------- */
+ /* SKELETON */
+ /* -------------------------------------------------------- */
+
+ "Skeleton"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /* -------------------------------------------------------- */
+ /* CARD */
+ /* -------------------------------------------------------- */
+
+ "Card"
+
+
+
+ "Card Title">}
+ content={"This is the card body content."
}/>
+
+
+
+
+ /* -------------------------------------------------------- */
+ /* BREADCRUMBS */
+ /* -------------------------------------------------------- */
+
+ "Breadcrumbs"
+
+ "Home">},
+ {"Components", <>"Components">},
+ {"Breadcrumbs", <>"Breadcrumbs">}
+ ]}/>
+
+
+ /* -------------------------------------------------------- */
+ /* TABS */
+ /* -------------------------------------------------------- */
+
+ "Tabs"
+
+ >, iconBefore: <>>, iconAfter: <>>},
+ {key: "tab2", label: "Tab 2", content: <>>, iconBefore: <>>, iconAfter: <>>},
+ {key: "tab3", label: "Tab 3", content: <>>, iconBefore: <>>, iconAfter: <>>}
+ ]}/>
+
+
+ /* -------------------------------------------------------- */
+ /* TABLE */
+ /* -------------------------------------------------------- */
+
+ "Table"
+
+ )
+ ]},
+ {"row2", [
+ Ui.Cell.String("Bob"),
+ Ui.Cell.String("Designer"),
+ Ui.Cell.Html()
+ ]}
+ ]}/>
+
+
+ /* -------------------------------------------------------- */
+ /* PAGINATION */
+ /* -------------------------------------------------------- */
+
+ "Pagination"
+
+
+
+
+ /* -------------------------------------------------------- */
+ /* ICON */
+ /* -------------------------------------------------------- */
+
+ "Icon"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /* -------------------------------------------------------- */
+ /* CIRCULAR PROGRESS */
+ /* -------------------------------------------------------- */
+
+ "CircularProgress"
+
+
+
+
+
+
+ /* -------------------------------------------------------- */
+ /* ILLUSTRATED MESSAGE */
+ /* -------------------------------------------------------- */
+
+ "IllustratedMessage"
+
+ "Nothing to show right now.">}
+ title={<>"No Data">}/>
+
+
+ /* -------------------------------------------------------- */
+ /* DARK MODE TOGGLE */
+ /* -------------------------------------------------------- */
+
+ "DarkModeToggle"
+
+
+
+
+ /* -------------------------------------------------------- */
+ /* FILE INPUT */
+ /* -------------------------------------------------------- */
+
+ "FileInput"
+
+
+
+
+
+
+ /* -------------------------------------------------------- */
+ /* COLOR PICKER */
+ /* -------------------------------------------------------- */
+
+ "ColorPicker"
+
+
+
+
+
+
+
+ }
+}
diff --git a/source/RadioGroup.mint b/source/RadioGroup.mint
new file mode 100644
index 0000000..bcb3fd0
--- /dev/null
+++ b/source/RadioGroup.mint
@@ -0,0 +1,143 @@
+/* A radio group component for selecting a single option from a list. */
+component Ui.RadioGroup {
+ /* The change event handler, called with the selected item key. */
+ property onChange : Function(String, Promise(Void)) = Promise.never1
+
+ /* The size of the radio group. */
+ property size : Ui.Size = Ui.Size.Inherit
+
+ /* The list of items to display. Each item is a tuple of (key, label). */
+ property items : Array(Tuple(String, String)) = []
+
+ /* The currently selected item key. */
+ property value : String = ""
+
+ /* Whether or not the radio group is disabled. */
+ property disabled : Bool = false
+
+ /* Whether or not the radio group is invalid. */
+ property invalid : Bool = false
+
+ /* Whether or not to layout the items horizontally. */
+ property horizontal : Bool = false
+
+ /* Styles for the radio group container. */
+ style base {
+ font-size: #{Ui.Size.toString(size)};
+ font-family: var(--font-family);
+
+ display: grid;
+ grid-gap: 0.5em;
+
+ if horizontal {
+ grid-auto-flow: column;
+ grid-auto-columns: max-content;
+ }
+
+ if disabled {
+ filter: saturate(0) brightness(0.8) contrast(0.5);
+ cursor: not-allowed;
+ pointer-events: none;
+ }
+ }
+
+ /* Styles for a radio item. */
+ style item {
+ align-items: center;
+ grid-gap: 0.5em;
+ display: grid;
+
+ grid-template-columns: auto 1fr;
+ cursor: pointer;
+
+ if disabled {
+ cursor: not-allowed;
+ }
+ }
+
+ /* Styles for the radio circle (unchecked). */
+ style radio {
+ border: 0.125em solid var(--input-border);
+ background: var(--input-color);
+
+ box-sizing: border-box;
+ border-radius: 50%;
+ height: 1.25em;
+ width: 1.25em;
+
+ display: grid;
+ place-items: center;
+
+ if invalid {
+ border-color: var(--input-invalid-border);
+ }
+ }
+
+ /* Styles for the radio circle (checked). */
+ style radioChecked {
+ border: 0.125em solid var(--primary-color);
+ background: var(--input-color);
+
+ box-sizing: border-box;
+ border-radius: 50%;
+ height: 1.25em;
+ width: 1.25em;
+
+ display: grid;
+ place-items: center;
+ }
+
+ /* Styles for the inner dot of a checked radio. */
+ style dot {
+ background: var(--primary-color);
+ border-radius: 50%;
+ height: 0.5em;
+ width: 0.5em;
+ }
+
+ /* Styles for the label text. */
+ style label {
+ line-height: 1.4;
+ color: var(--content-text);
+
+ if invalid {
+ color: var(--input-invalid-text);
+ }
+ }
+
+ /* Handles clicking a radio item. */
+ fun handleClick (key : String) : Promise(Void) {
+ onChange(key)
+ }
+
+ /* Renders the radio group. */
+ fun render : Html {
+
+ {
+ for item of items {
+ let {key, label} =
+ item
+
+ let checked =
+ key == value
+
+
+
+ if checked {
+
+
+
+ } else {
+
+ }
+
+ label
+
+ }
+ }
+
+ }
+}
diff --git a/source/Separator.mint b/source/Separator.mint
new file mode 100644
index 0000000..ede85d4
--- /dev/null
+++ b/source/Separator.mint
@@ -0,0 +1,38 @@
+/* A visual divider to separate content, either horizontally or vertically. */
+component Ui.Separator {
+ /* The size of the separator. */
+ property size : Ui.Size = Ui.Size.Inherit
+
+ /* Whether or not the separator is vertical. */
+ property vertical : Bool = false
+
+ /* Styles for a horizontal separator. */
+ style horizontal {
+ font-size: #{Ui.Size.toString(size)};
+ border: 0;
+
+ border-top: 0.0625em solid var(--content-border);
+ width: 100%;
+ margin: 0;
+ }
+
+ /* Styles for a vertical separator. */
+ style vertical {
+ font-size: #{Ui.Size.toString(size)};
+ border: 0;
+
+ border-left: 0.0625em solid var(--content-border);
+ align-self: stretch;
+ min-height: 1em;
+ margin: 0;
+ }
+
+ /* Renders the separator. */
+ fun render : Html {
+ if vertical {
+
+ } else {
+
+ }
+ }
+}
diff --git a/source/Skeleton.mint b/source/Skeleton.mint
new file mode 100644
index 0000000..5441461
--- /dev/null
+++ b/source/Skeleton.mint
@@ -0,0 +1,56 @@
+/* A loading placeholder that mimics the shape of content. */
+component Ui.Skeleton {
+ /* The size of the skeleton. */
+ property size : Ui.Size = Ui.Size.Inherit
+
+ /* The width of the skeleton (CSS value). */
+ property width : String = "100%"
+
+ /* The height of the skeleton (CSS value). */
+ property height : String = "1em"
+
+ /* The border radius of the skeleton (CSS value). */
+ property borderRadius : String = "0.375em"
+
+ /* Whether or not the skeleton is circular. */
+ property circular : Bool = false
+
+ /* Styles for the skeleton. */
+ style base {
+ font-size: #{Ui.Size.toString(size)};
+
+ background: linear-gradient(
+ 90deg,
+ var(--content-faded-color) 25%,
+ var(--content-faded-border) 50%,
+ var(--content-faded-color) 75%
+ );
+
+ background-size: 200% 100%;
+ animation: shimmer 1.5s ease-in-out infinite;
+
+ width: #{width};
+ height: #{height};
+
+ if circular {
+ border-radius: 50%;
+ } else {
+ border-radius: #{borderRadius};
+ }
+
+ @keyframes shimmer {
+ 0% {
+ background-position: 200% 0;
+ }
+
+ 100% {
+ background-position: -200% 0;
+ }
+ }
+ }
+
+ /* Renders the skeleton. */
+ fun render : Html {
+
+ }
+}
diff --git a/source/TimePicker.mint b/source/TimePicker.mint
new file mode 100644
index 0000000..6966582
--- /dev/null
+++ b/source/TimePicker.mint
@@ -0,0 +1,244 @@
+/* A time picker component for selecting hours and minutes. */
+component Ui.TimePicker {
+ /* The change event handler. */
+ property onChange : Function(Time, Promise(Void)) = Promise.never1
+
+ /* The language to use for time formatting. */
+ property language : Time.Format.Language = Time.Format.ENGLISH
+
+ /* The position of the dropdown. */
+ property position : Ui.Position = Ui.Position.BottomRight
+
+ /* The size of the picker. */
+ property size : Ui.Size = Ui.Size.Inherit
+
+ /* The current value (as `Time`). */
+ property value : Time = Time.now()
+
+ /* Whether or not the picker is disabled. */
+ property disabled : Bool = false
+
+ /* Whether or not the picker is invalid. */
+ property invalid : Bool = false
+
+ /* The offset of the dropdown from the input. */
+ property offset : Number = 5
+
+ /* The formatter for the time in the input. */
+ property formatter = Time.format
+
+ /* The format for the time in the input. */
+ property format : String = "%H:%M"
+
+ /* The step in minutes for the minute selector. */
+ property step : Number = 1
+
+ style label {
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+ }
+
+ /* Styles for the time panel container. */
+ style panel {
+ font-family: var(--font-family);
+ grid-template-columns: 1fr 1fr;
+ grid-gap: 0.25em;
+ display: grid;
+ padding: 0.5em;
+ }
+
+ /* Styles for a column of time values. */
+ style column {
+ scrollbar-width: thin;
+ overflow-y: auto;
+ max-height: 15em;
+ }
+
+ /* Styles for the column header. */
+ style columnHeader {
+ text-transform: uppercase;
+ text-align: center;
+ font-weight: bold;
+ font-size: 0.74em;
+ padding: 0.5em 0;
+ opacity: 0.5;
+ }
+
+ /* Styles for a time cell. */
+ style cell (selected : Bool) {
+ border-radius: 0.25em;
+ text-align: center;
+ line-height: 2em;
+ cursor: pointer;
+
+ if selected {
+ background: var(--primary-color);
+ color: var(--primary-text);
+ }
+
+ &:hover {
+ background: var(--primary-color);
+ color: var(--primary-text);
+ }
+ }
+
+ /* Returns the current UTC hour from the value. */
+ get currentHour : Number {
+ `#{value}.getUTCHours()`
+ }
+
+ /* Returns the current UTC minute from the value. */
+ get currentMinute : Number {
+ `#{value}.getUTCMinutes()`
+ }
+
+ /* Handles clicking on an hour cell, reading the value from the data attribute. */
+ fun handleHourSelect (event : Html.Event) : Promise(Void) {
+ let hour =
+ event.target
+ |> Dom.getAttribute("data-value")
+ |> Maybe.withDefault("0")
+ |> Number.fromString()
+ |> Maybe.withDefault(0)
+
+ onChange(
+ `(() => {
+ var d = new Date(#{value}.getTime());
+ d.setUTCHours(#{hour});
+ return d;
+ })()`)
+ }
+
+ /* Handles clicking on a minute cell, reading the value from the data attribute. */
+ fun handleMinuteSelect (event : Html.Event) : Promise(Void) {
+ let minute =
+ event.target
+ |> Dom.getAttribute("data-value")
+ |> Maybe.withDefault("0")
+ |> Number.fromString()
+ |> Maybe.withDefault(0)
+
+ onChange(
+ `(() => {
+ var d = new Date(#{value}.getTime());
+ d.setUTCMinutes(#{minute});
+ return d;
+ })()`)
+ }
+
+ /* Handles the keydown event. */
+ fun handleKeyDown (event : Html.Event) {
+ case event.keyCode {
+ 37 =>
+ onChange(
+ `(() => {
+ var d = new Date(#{value}.getTime());
+ d.setUTCMinutes(d.getUTCMinutes() - #{step});
+ return d;
+ })()`)
+
+ 38 =>
+ {
+ Html.Event.preventDefault(event)
+
+ onChange(
+ `(() => {
+ var d = new Date(#{value}.getTime());
+ d.setUTCHours(d.getUTCHours() - 1);
+ return d;
+ })()`)
+ }
+
+ 39 =>
+ onChange(
+ `(() => {
+ var d = new Date(#{value}.getTime());
+ d.setUTCMinutes(d.getUTCMinutes() + #{step});
+ return d;
+ })()`)
+
+ 40 =>
+ {
+ Html.Event.preventDefault(event)
+
+ onChange(
+ `(() => {
+ var d = new Date(#{value}.getTime());
+ d.setUTCHours(d.getUTCHours() + 1);
+ return d;
+ })()`)
+ }
+
+ => next { }
+ }
+ }
+
+ /* Renders the time picker. */
+ fun render : Html {
+ let hours =
+ Array.range(0, 23)
+
+ let minutes =
+ Array.range(0, 59)
+ |> Array.select(
+ (m : Number) : Bool { `#{m} % #{step} === 0` })
+
+ let panel =
+
+
+
+
+
+ {
+ for hour of hours {
+
+
+ Number.toString(hour)
+ |> String.padStart("0", 2)
+
+ }
+ }
+
+
+
+
+
+
+ {
+ for minute of minutes {
+
+
+ Number.toString(minute)
+ |> String.padStart("0", 2)
+
+ }
+ }
+
+
+
+
+
+ let label =
+ Maybe.Just(formatter(value, language, format))
+
+
+ }
+}
diff --git a/source/Tooltip.mint b/source/Tooltip.mint
new file mode 100644
index 0000000..7569429
--- /dev/null
+++ b/source/Tooltip.mint
@@ -0,0 +1,154 @@
+/* A tooltip component that shows a hint on hover. */
+component Ui.Tooltip {
+ /* The tooltip text to display. */
+ property content : String = ""
+
+ /* The position of the tooltip. */
+ property position : Ui.Position = Ui.Position.TopCenter
+
+ /* The size of the tooltip. */
+ property size : Ui.Size = Ui.Size.Inherit
+
+ /* The child element to wrap. */
+ property children : Array(Html) = []
+
+ /* The offset from the element. */
+ property offset : Number = 8
+
+ /* Whether or not the tooltip is visible. */
+ state visible : Bool = false
+
+ /* Styles for the tooltip wrapper. */
+ style base {
+ font-size: #{Ui.Size.toString(size)};
+ display: inline-block;
+ position: relative;
+ }
+
+ /* Styles for the tooltip content. */
+ style tooltip {
+ font-family: var(--font-family);
+ background: var(--content-text);
+ color: var(--content-color);
+
+ border-radius: 0.375em;
+ padding: 0.375em 0.625em;
+
+ pointer-events: none;
+ position: absolute;
+ white-space: nowrap;
+ font-size: 0.8125em;
+ line-height: 1.4;
+ z-index: 1000;
+
+ if visible {
+ transition: opacity 150ms 0ms ease;
+ opacity: 1;
+ } else {
+ transition: opacity 150ms 0ms ease;
+ opacity: 0;
+ }
+
+ case position {
+ Ui.Position.TopCenter =>
+ {
+ transform: translateX(-50%);
+ bottom: calc(100% + #{offset}px);
+ left: 50%;
+ }
+
+ Ui.Position.TopLeft =>
+ {
+ bottom: calc(100% + #{offset}px);
+ left: 0;
+ }
+
+ Ui.Position.TopRight =>
+ {
+ bottom: calc(100% + #{offset}px);
+ right: 0;
+ }
+
+ Ui.Position.BottomCenter =>
+ {
+ transform: translateX(-50%);
+ top: calc(100% + #{offset}px);
+ left: 50%;
+ }
+
+ Ui.Position.BottomLeft =>
+ {
+ top: calc(100% + #{offset}px);
+ left: 0;
+ }
+
+ Ui.Position.BottomRight =>
+ {
+ top: calc(100% + #{offset}px);
+ right: 0;
+ }
+
+ Ui.Position.LeftCenter =>
+ {
+ transform: translateY(-50%);
+ right: calc(100% + #{offset}px);
+ top: 50%;
+ }
+
+ Ui.Position.LeftTop =>
+ {
+ right: calc(100% + #{offset}px);
+ top: 0;
+ }
+
+ Ui.Position.LeftBottom =>
+ {
+ right: calc(100% + #{offset}px);
+ bottom: 0;
+ }
+
+ Ui.Position.RightCenter =>
+ {
+ transform: translateY(-50%);
+ left: calc(100% + #{offset}px);
+ top: 50%;
+ }
+
+ Ui.Position.RightTop =>
+ {
+ left: calc(100% + #{offset}px);
+ top: 0;
+ }
+
+ Ui.Position.RightBottom =>
+ {
+ left: calc(100% + #{offset}px);
+ bottom: 0;
+ }
+ }
+ }
+
+ /* Shows the tooltip. */
+ fun handleMouseEnter (event : Html.Event) : Promise(Void) {
+ next { visible: true }
+ }
+
+ /* Hides the tooltip. */
+ fun handleMouseLeave (event : Html.Event) : Promise(Void) {
+ next { visible: false }
+ }
+
+ /* Renders the tooltip. */
+ fun render : Html {
+
+
+ children
+
+ if String.isNotBlank(content) {
+ content
+ }
+
+ }
+}
diff --git a/test/Accordion.mint b/test/Accordion.mint
new file mode 100644
index 0000000..f788fc9
--- /dev/null
+++ b/test/Accordion.mint
@@ -0,0 +1,26 @@
+suite "Ui.Accordion" {
+ test "renders all section headers" {
+ "Content 1"},
+ {"s2", "Section 2", "Content 2"
}
+ ]}/>
+ |> Test.Html.start()
+ |> Test.Html.assertTextOf("button:first-child", "Section 1")
+ }
+
+ test "renders the chevron icon" {
+ "Body"}]}/>
+ |> Test.Html.start()
+ |> Test.Html.assertElementExists("svg")
+ }
+
+ test "renders without errors when disabled" {
+ "Body"}]}
+ disabled={true}/>
+ |> Test.Html.start()
+ |> Test.Html.assertElementExists("div")
+ }
+}
diff --git a/test/Alert.mint b/test/Alert.mint
new file mode 100644
index 0000000..93fdfdb
--- /dev/null
+++ b/test/Alert.mint
@@ -0,0 +1,36 @@
+suite "Ui.Alert" {
+ test "renders the title" {
+
+ |> Test.Html.start()
+ |> Test.Html.assertElementExists("div[role='alert']")
+ }
+
+ test "renders the icon" {
+
+ |> Test.Html.start()
+ |> Test.Html.assertElementExists("svg")
+ }
+
+ test "renders the close button when closeable" {
+
+ |> Test.Html.start()
+ |> Test.Html.assertElementExists("button")
+ }
+
+ test "does not render close button by default" {
+
+ |> Test.Html.start()
+ |> Test.Html.assertElementNotExists("button")
+ }
+
+ test "handles close event" {
+ let handler =
+ Promise.never
+ |> Test.Context.spyOn()
+
+
+ |> Test.Html.start()
+ |> Test.Html.triggerClick("button")
+ |> Test.Context.assertFunctionCalled(handler)
+ }
+}
diff --git a/test/Avatar.mint b/test/Avatar.mint
new file mode 100644
index 0000000..4edb6e5
--- /dev/null
+++ b/test/Avatar.mint
@@ -0,0 +1,19 @@
+suite "Ui.Avatar" {
+ test "renders initials fallback" {
+
+ |> Test.Html.start()
+ |> Test.Html.assertTextOf("span", "JD")
+ }
+
+ test "renders image when src is provided" {
+
+ |> Test.Html.start()
+ |> Test.Html.assertElementExists("img")
+ }
+
+ test "renders default icon when no src or initials" {
+
+ |> Test.Html.start()
+ |> Test.Html.assertElementExists("svg")
+ }
+}
diff --git a/test/Badge.mint b/test/Badge.mint
new file mode 100644
index 0000000..1819672
--- /dev/null
+++ b/test/Badge.mint
@@ -0,0 +1,13 @@
+suite "Ui.Badge" {
+ test "renders the label" {
+
+ |> Test.Html.start()
+ |> Test.Html.assertTextOf("span", "New")
+ }
+
+ test "renders with a different label" {
+
+ |> Test.Html.start()
+ |> Test.Html.assertTextOf("span", "3")
+ }
+}
diff --git a/test/DateTimePicker.mint b/test/DateTimePicker.mint
new file mode 100644
index 0000000..8c0eb7b
--- /dev/null
+++ b/test/DateTimePicker.mint
@@ -0,0 +1,44 @@
+suite "Ui.DateTimePicker" {
+ test "renders the formatted datetime label" {
+
+ |> Test.Html.start()
+ |> Test.Html.assertTextOf("div div div", "2024-01-15 14:30")
+ }
+
+ test "renders with seconds format" {
+
+ |> Test.Html.start()
+ |> Test.Html.assertTextOf("div div div", "2024-06-01 09:05:42")
+ }
+
+ test "renders the calendar icon" {
+
+ |> Test.Html.start()
+ |> Test.Html.assertElementExists("svg")
+ }
+
+ test "renders without errors when disabled" {
+
+ |> Test.Html.start()
+ |> Test.Html.assertElementExists("div")
+ }
+
+ test "renders with timezone enabled" {
+
+ |> Test.Html.start()
+ |> Test.Html.assertElementExists("div")
+ }
+}
+
+suite "Ui.DateTimePicker - Disabled" {
+ test "does not open the dropdown when disabled" {
+
+ |> Test.Html.start()
+ |> Test.Html.triggerClick("div")
+ |> Test.Html.assertElementExists("div")
+ }
+}
diff --git a/test/RadioGroup.mint b/test/RadioGroup.mint
new file mode 100644
index 0000000..6480a8c
--- /dev/null
+++ b/test/RadioGroup.mint
@@ -0,0 +1,34 @@
+suite "Ui.RadioGroup" {
+ test "renders all items" {
+
+ |> Test.Html.start()
+ |> Test.Html.assertElementExists("div[role='radiogroup']")
+ }
+
+ test "handles change event" {
+ let handler =
+ (key : String) { Promise.never() }
+ |> Test.Context.spyOn()
+
+
+ |> Test.Html.start()
+ |> Test.Html.triggerClick("div[role='radio']:last-child")
+ |> Test.Context.assertFunctionCalled(handler)
+ }
+}
+
+suite "Ui.RadioGroup - Disabled" {
+ test "renders without errors when disabled" {
+
+ |> Test.Html.start()
+ |> Test.Html.assertElementExists("div[role='radiogroup']")
+ }
+}
diff --git a/test/Separator.mint b/test/Separator.mint
new file mode 100644
index 0000000..6da4694
--- /dev/null
+++ b/test/Separator.mint
@@ -0,0 +1,13 @@
+suite "Ui.Separator" {
+ test "renders a horizontal separator by default" {
+
+ |> Test.Html.start()
+ |> Test.Html.assertElementExists("hr")
+ }
+
+ test "renders a vertical separator" {
+
+ |> Test.Html.start()
+ |> Test.Html.assertElementExists("hr")
+ }
+}
diff --git a/test/Skeleton.mint b/test/Skeleton.mint
new file mode 100644
index 0000000..3128213
--- /dev/null
+++ b/test/Skeleton.mint
@@ -0,0 +1,13 @@
+suite "Ui.Skeleton" {
+ test "renders without errors" {
+
+ |> Test.Html.start()
+ |> Test.Html.assertElementExists("div")
+ }
+
+ test "is hidden from accessibility tree" {
+
+ |> Test.Html.start()
+ |> Test.Html.assertElementExists("div[aria-hidden='true']")
+ }
+}
diff --git a/test/TimePicker.mint b/test/TimePicker.mint
new file mode 100644
index 0000000..bb39930
--- /dev/null
+++ b/test/TimePicker.mint
@@ -0,0 +1,36 @@
+suite "Ui.TimePicker" {
+ test "renders the formatted time label" {
+
+ |> Test.Html.start()
+ |> Test.Html.assertTextOf("div div div", "14:30")
+ }
+
+ test "renders with seconds format" {
+
+ |> Test.Html.start()
+ |> Test.Html.assertTextOf("div div div", "09:05:42")
+ }
+
+ test "renders the clock icon" {
+
+ |> Test.Html.start()
+ |> Test.Html.assertElementExists("svg")
+ }
+
+ test "renders without errors when disabled" {
+
+ |> Test.Html.start()
+ |> Test.Html.assertElementExists("div")
+ }
+}
+
+suite "Ui.TimePicker - Disabled" {
+ test "does not open the dropdown when disabled" {
+
+ |> Test.Html.start()
+ |> Test.Html.triggerClick("div")
+ |> Test.Html.assertElementExists("div")
+ }
+}
diff --git a/test/Tooltip.mint b/test/Tooltip.mint
new file mode 100644
index 0000000..78a7e06
--- /dev/null
+++ b/test/Tooltip.mint
@@ -0,0 +1,21 @@
+suite "Ui.Tooltip" {
+ test "renders children" {
+
+ "Hello"
+
+ |> Test.Html.start()
+ |> Test.Html.assertTextOf("#child", "Hello")
+ }
+
+ test "renders tooltip text" {
+
+ |> Test.Html.start()
+ |> Test.Html.assertElementExists("div[role='tooltip']")
+ }
+
+ test "does not render tooltip when content is empty" {
+
+ |> Test.Html.start()
+ |> Test.Html.assertElementNotExists("div[role='tooltip']")
+ }
+}