From eb569a8fc67c1ec29b3ab9d14e320486b9b73e5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Wed, 10 Jun 2026 08:35:29 -0300 Subject: [PATCH] [Bug Fix] Rebuild MCP registry snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registry was stale since #391 — missing Toast (#389), Toggle/ToggleGroup (#392), Combobox popover width (#397) and Sheet initial open state (#402) changes. Regenerated with `cd mcp && bundle exec exe/ruby-ui-mcp-build`. Fixes the "MCP registry up to date" CI check failing on main and all open PRs. --- mcp/data/registry.json | 177 +++++++++++++++++++++++++++++++++-------- 1 file changed, 146 insertions(+), 31 deletions(-) diff --git a/mcp/data/registry.json b/mcp/data/registry.json index 31cd7b9e..b7cf12e2 100644 --- a/mcp/data/registry.json +++ b/mcp/data/registry.json @@ -214,15 +214,19 @@ "files": [ { "path": "avatar.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Avatar < Base\n SIZES = {\n xs: \"h-4 w-4 text-[0.5rem]\",\n sm: \"h-6 w-6 text-xs\",\n md: \"h-10 w-10 text-base\",\n lg: \"h-14 w-14 text-xl\",\n xl: \"h-20 w-20 text-3xl\"\n }\n\n def initialize(size: :md, **attrs)\n @size = size\n @size_classes = SIZES[@size]\n super(**attrs)\n end\n\n def view_template(&)\n span(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: [\"relative flex shrink-0 overflow-hidden rounded-full\", @size_classes]\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Avatar < Base\n SIZES = {\n xs: \"h-4 w-4 text-[0.5rem]\",\n sm: \"h-6 w-6 text-xs\",\n md: \"h-10 w-10 text-base\",\n lg: \"h-14 w-14 text-xl\",\n xl: \"h-20 w-20 text-3xl\"\n }\n\n def initialize(size: :md, **attrs)\n @size = size\n @size_classes = SIZES[@size]\n super(**attrs)\n end\n\n def view_template(&)\n span(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n controller: \"ruby-ui--avatar\"\n },\n class: [\"relative flex shrink-0 overflow-hidden rounded-full\", @size_classes]\n }\n end\n end\nend\n" + }, + { + "path": "avatar_controller.js", + "content": "import { Controller } from \"@hotwired/stimulus\";\n\nexport default class extends Controller {\n static targets = [\"image\", \"fallback\"];\n\n connect() {\n if (!this.hasImageTarget) {\n return;\n }\n\n if (this.imageTarget.complete && this.imageTarget.naturalWidth > 0) {\n this.showImage();\n } else {\n // Image not yet loaded (or failed): hide it so the fallback shows.\n // Image visibility is restored by the load/error handlers.\n this.showFallback();\n }\n }\n\n showImage() {\n this.imageTargets.forEach((image) => image.classList.remove(\"hidden\"));\n this.fallbackTargets.forEach((fallback) =>\n fallback.classList.add(\"hidden\"),\n );\n }\n\n showFallback() {\n this.imageTargets.forEach((image) => image.classList.add(\"hidden\"));\n this.fallbackTargets.forEach((fallback) =>\n fallback.classList.remove(\"hidden\"),\n );\n }\n}\n" }, { "path": "avatar_fallback.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class AvatarFallback < Base\n def view_template(&)\n span(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"flex h-full w-full items-center justify-center rounded-full bg-muted\"\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class AvatarFallback < Base\n def view_template(&)\n span(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n ruby_ui__avatar_target: \"fallback\"\n },\n class: \"flex h-full w-full items-center justify-center rounded-full bg-muted\"\n }\n end\n end\nend\n" }, { "path": "avatar_image.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class AvatarImage < Base\n def initialize(src:, alt: \"\", **attrs)\n @src = src\n @alt = alt\n super(**attrs)\n end\n\n def view_template\n img(**attrs)\n end\n\n private\n\n def default_attrs\n {\n loading: \"lazy\",\n class: \"aspect-square h-full w-full\",\n alt: @alt,\n src: @src\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class AvatarImage < Base\n def initialize(src:, alt: \"\", **attrs)\n @src = src\n @alt = alt\n super(**attrs)\n end\n\n def view_template\n img(**attrs)\n end\n\n private\n\n def default_attrs\n {\n loading: \"lazy\",\n data: {\n ruby_ui__avatar_target: \"image\",\n action: \"load->ruby-ui--avatar#showImage error->ruby-ui--avatar#showFallback\"\n },\n class: \"aspect-square h-full w-full\",\n alt: @alt,\n src: @src\n }\n end\n end\nend\n" } ], "dependencies": { @@ -457,7 +461,7 @@ "files": [ { "path": "calendar.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Calendar < Base\n def initialize(selected_date: nil, input_id: nil, date_format: \"yyyy-MM-dd\", **attrs)\n @selected_date = selected_date\n @input_id = input_id\n @date_format = date_format\n super(**attrs)\n end\n\n def view_template\n div(**attrs) do\n RubyUI.CalendarHeader do\n RubyUI.CalendarTitle\n RubyUI.CalendarPrev\n RubyUI.CalendarNext\n end\n RubyUI.CalendarBody # Where the calendar is rendered (Weekdays and Days)\n RubyUI.CalendarWeekdays # Template for the weekdays\n RubyUI.CalendarDays # Template for the days\n end\n end\n\n private\n\n def default_attrs\n {\n class: \"p-3 space-y-4\",\n data: {\n controller: \"ruby-ui--calendar\",\n ruby_ui__calendar_selected_date_value: @selected_date&.to_s,\n ruby_ui__calendar_format_value: @date_format,\n ruby_ui__calendar_ruby_ui__calendar_input_outlet: @input_id\n }\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Calendar < Base\n def initialize(selected_date: nil, min_date: nil, input_id: nil, date_format: \"yyyy-MM-dd\", **attrs)\n @selected_date = selected_date\n @min_date = min_date\n @input_id = input_id\n @date_format = date_format\n super(**attrs)\n end\n\n def view_template\n div(**attrs) do\n RubyUI.CalendarHeader do\n RubyUI.CalendarTitle\n RubyUI.CalendarPrev\n RubyUI.CalendarNext\n end\n RubyUI.CalendarBody # Where the calendar is rendered (Weekdays and Days)\n RubyUI.CalendarWeekdays # Template for the weekdays\n RubyUI.CalendarDays # Template for the days\n end\n end\n\n private\n\n def default_attrs\n {\n class: \"p-3 space-y-4\",\n data: {\n controller: \"ruby-ui--calendar\",\n ruby_ui__calendar_selected_date_value: @selected_date&.to_s,\n ruby_ui__calendar_min_date_value: @min_date&.to_s,\n ruby_ui__calendar_format_value: @date_format,\n ruby_ui__calendar_ruby_ui__calendar_input_outlet: @input_id\n }\n }\n end\n end\nend\n" }, { "path": "calendar_body.rb", @@ -465,11 +469,11 @@ }, { "path": "calendar_controller.js", - "content": "import { Controller } from \"@hotwired/stimulus\";\nimport Mustache from \"mustache\";\n\nexport default class extends Controller {\n static targets = [\n \"calendar\",\n \"title\",\n \"weekdaysTemplate\",\n \"selectedDateTemplate\",\n \"todayDateTemplate\",\n \"currentMonthDateTemplate\",\n \"otherMonthDateTemplate\",\n ];\n static values = {\n selectedDate: {\n type: String,\n default: null,\n },\n viewDate: {\n type: String,\n default: new Date().toISOString().slice(0, 10),\n },\n format: {\n type: String,\n default: \"yyyy-MM-dd\", // Default format\n },\n };\n static outlets = [\"ruby-ui--calendar-input\"];\n\n initialize() {\n this.updateCalendar(); // Initial calendar render\n }\n\n nextMonth(e) {\n e.preventDefault();\n this.viewDateValue = this.adjustMonth(1);\n }\n\n prevMonth(e) {\n e.preventDefault();\n this.viewDateValue = this.adjustMonth(-1);\n }\n\n selectDay(e) {\n e.preventDefault();\n // Set the selected date value\n this.selectedDateValue = e.currentTarget.dataset.day;\n }\n\n selectedDateValueChanged(value, prevValue) {\n // update the viewDateValue to the first day of month of the selected date (This will trigger updateCalendar() function)\n const newViewDate = new Date(this.selectedDateValue);\n newViewDate.setDate(2); // set the day to the 2nd (to avoid issues with months with different number of days and timezones)\n this.viewDateValue = newViewDate.toISOString().slice(0, 10);\n\n // Re-render the calendar\n this.updateCalendar();\n\n // update the input value\n this.rubyUiCalendarInputOutlets.forEach((outlet) => {\n const formattedDate = this.formatDate(this.selectedDate());\n outlet.setValue(formattedDate);\n });\n }\n\n viewDateValueChanged(value, prevValue) {\n this.updateCalendar();\n }\n\n adjustMonth(adjustment) {\n const date = this.viewDate();\n date.setDate(2); // set the day to the 2nd (to avoid issues with months with different number of days and timezones)\n date.setMonth(date.getMonth() + adjustment);\n return date.toISOString().slice(0, 10);\n }\n\n updateCalendar() {\n // Update the title with month and year\n this.titleTarget.textContent = this.monthAndYear();\n this.calendarTarget.innerHTML = this.calendarHTML();\n }\n\n calendarHTML() {\n return this.weekdaysTemplateTarget.innerHTML + this.calendarDays();\n }\n\n calendarDays() {\n return this.getFullWeeksStartAndEndInMonth()\n .map((week) => this.renderWeek(week))\n .join(\"\");\n }\n\n renderWeek(week) {\n const days = week\n .map((day) => {\n return this.renderDay(day);\n })\n .join(\"\");\n return `${days}`;\n }\n\n renderDay(day) {\n const today = new Date();\n let dateHTML = \"\";\n const data = { day: day, dayDate: day.getDate() };\n\n if (day.toDateString() === this.selectedDate().toDateString()) {\n // selectedDate\n // Render the selected date template target innerHTML with Mustache\n dateHTML = Mustache.render(\n this.selectedDateTemplateTarget.innerHTML,\n data,\n );\n } else if (day.toDateString() === today.toDateString()) {\n // todayDate\n dateHTML = Mustache.render(this.todayDateTemplateTarget.innerHTML, data);\n } else if (day.getMonth() === this.viewDate().getMonth()) {\n // currentMonthDate\n dateHTML = Mustache.render(\n this.currentMonthDateTemplateTarget.innerHTML,\n data,\n );\n } else {\n // otherMonthDate\n dateHTML = Mustache.render(\n this.otherMonthDateTemplateTarget.innerHTML,\n data,\n );\n }\n return dateHTML;\n }\n\n monthAndYear() {\n const month = this.viewDate().toLocaleString(\"en-US\", { month: \"long\" });\n const year = this.viewDate().getFullYear();\n return `${month} ${year}`;\n }\n\n selectedDate() {\n return new Date(this.selectedDateValue);\n }\n\n viewDate() {\n return this.viewDateValue\n ? new Date(this.viewDateValue)\n : this.selectedDate();\n }\n\n getFullWeeksStartAndEndInMonth() {\n const month = this.viewDate().getMonth();\n const year = this.viewDate().getFullYear();\n\n let weeks = [],\n firstDate = new Date(year, month, 1),\n lastDate = new Date(year, month + 1, 0),\n numDays = lastDate.getDate();\n\n let start = 1;\n let end;\n if (firstDate.getDay() === 1) {\n end = 7;\n } else if (firstDate.getDay() === 0) {\n let preMonthEndDay = new Date(year, month, 0);\n start = preMonthEndDay.getDate() - 6 + 1;\n end = 1;\n } else {\n let preMonthEndDay = new Date(year, month, 0);\n start = preMonthEndDay.getDate() + 1 - firstDate.getDay() + 1;\n end = 7 - firstDate.getDay() + 1;\n weeks.push({\n start: start,\n end: end,\n });\n start = end + 1;\n end = end + 7;\n }\n while (start <= numDays) {\n weeks.push({\n start: start,\n end: end,\n });\n start = end + 1;\n end = end + 7;\n end = start === 1 && end === 8 ? 1 : end;\n if (end > numDays && start <= numDays) {\n end = end - numDays;\n weeks.push({\n start: start,\n end: end,\n });\n break;\n }\n }\n // *** the magic starts here\n return weeks.map(({ start, end }, index) => {\n const sub = +(start > end && index === 0);\n return Array.from({ length: 7 }, (_, index) => {\n const date = new Date(year, month - sub, start + index);\n return date;\n });\n });\n }\n\n formatDate(date) {\n const format = this.formatValue;\n const day = date.getDate();\n const month = date.getMonth() + 1;\n const year = date.getFullYear();\n const hours = date.getHours();\n const minutes = date.getMinutes();\n const seconds = date.getSeconds();\n const dayOfWeek = date.toLocaleString(\"en-US\", { weekday: \"long\" });\n const monthName = date.toLocaleString(\"en-US\", { month: \"long\" });\n const daySuffix = this.getDaySuffix(day);\n\n const map = {\n yyyy: year,\n MM: (\"0\" + month).slice(-2),\n dd: (\"0\" + day).slice(-2),\n HH: (\"0\" + hours).slice(-2),\n mm: (\"0\" + minutes).slice(-2),\n ss: (\"0\" + seconds).slice(-2),\n EEEE: dayOfWeek,\n MMMM: monthName,\n do: day + daySuffix,\n PPPP: `${dayOfWeek}, ${monthName} ${day}${daySuffix}, ${year}`,\n };\n\n const formattedDate = format.replace(\n /yyyy|MM|dd|HH|mm|ss|EEEE|MMMM|do|PPPP/g,\n (matched) => map[matched],\n );\n return formattedDate;\n }\n\n getDaySuffix(day) {\n if (day > 3 && day < 21) return \"th\";\n switch (day % 10) {\n case 1:\n return \"st\";\n case 2:\n return \"nd\";\n case 3:\n return \"rd\";\n default:\n return \"th\";\n }\n }\n}\n" + "content": "import { Controller } from \"@hotwired/stimulus\";\nimport Mustache from \"mustache\";\n\nexport default class extends Controller {\n static targets = [\n \"calendar\",\n \"title\",\n \"weekdaysTemplate\",\n \"disabledDateTemplate\",\n \"selectedDateTemplate\",\n \"todayDateTemplate\",\n \"currentMonthDateTemplate\",\n \"otherMonthDateTemplate\",\n ];\n static values = {\n selectedDate: {\n type: String,\n default: null,\n },\n minDate: {\n type: String,\n default: null,\n },\n viewDate: {\n type: String,\n default: new Date().toISOString().slice(0, 10),\n },\n format: {\n type: String,\n default: \"yyyy-MM-dd\", // Default format\n },\n };\n static outlets = [\"ruby-ui--calendar-input\"];\n\n initialize() {\n this.updateCalendar(); // Initial calendar render\n }\n\n nextMonth(e) {\n e.preventDefault();\n this.viewDateValue = this.adjustMonth(1);\n }\n\n prevMonth(e) {\n e.preventDefault();\n this.viewDateValue = this.adjustMonth(-1);\n }\n\n selectDay(e) {\n e.preventDefault();\n if (this.isDateDisabled(e.currentTarget.dataset.day)) return;\n\n // Set the selected date value\n this.selectedDateValue = e.currentTarget.dataset.day;\n }\n\n selectedDateValueChanged(value, prevValue) {\n const selectedDate = this.selectedDate();\n if (!selectedDate) {\n this.updateCalendar();\n return;\n }\n\n // update the viewDateValue to the first day of month of the selected date (This will trigger updateCalendar() function)\n const newViewDate = new Date(selectedDate);\n newViewDate.setDate(2); // set the day to the 2nd (to avoid issues with months with different number of days and timezones)\n this.viewDateValue = newViewDate.toISOString().slice(0, 10);\n\n // Re-render the calendar\n this.updateCalendar();\n\n // update the input value\n this.rubyUiCalendarInputOutlets.forEach((outlet) => {\n const formattedDate = this.formatDate(selectedDate);\n outlet.setValue(formattedDate);\n });\n }\n\n viewDateValueChanged(value, prevValue) {\n this.updateCalendar();\n }\n\n adjustMonth(adjustment) {\n const date = this.viewDate();\n date.setDate(2); // set the day to the 2nd (to avoid issues with months with different number of days and timezones)\n date.setMonth(date.getMonth() + adjustment);\n return date.toISOString().slice(0, 10);\n }\n\n updateCalendar() {\n // Update the title with month and year\n this.titleTarget.textContent = this.monthAndYear();\n this.calendarTarget.innerHTML = this.calendarHTML();\n }\n\n calendarHTML() {\n return this.weekdaysTemplateTarget.innerHTML + this.calendarDays();\n }\n\n calendarDays() {\n return this.getFullWeeksStartAndEndInMonth()\n .map((week) => this.renderWeek(week))\n .join(\"\");\n }\n\n renderWeek(week) {\n const days = week\n .map((day) => {\n return this.renderDay(day);\n })\n .join(\"\");\n return `${days}`;\n }\n\n renderDay(day) {\n const today = new Date();\n const selectedDate = this.selectedDate();\n let dateHTML = \"\";\n const data = { day: day, dayDate: day.getDate() };\n\n if (this.isDateDisabled(day)) {\n // disabledDate\n dateHTML = Mustache.render(\n this.disabledDateTemplateTarget.innerHTML,\n data,\n );\n } else if (\n selectedDate &&\n day.toDateString() === selectedDate.toDateString()\n ) {\n // selectedDate\n // Render the selected date template target innerHTML with Mustache\n dateHTML = Mustache.render(\n this.selectedDateTemplateTarget.innerHTML,\n data,\n );\n } else if (day.toDateString() === today.toDateString()) {\n // todayDate\n dateHTML = Mustache.render(this.todayDateTemplateTarget.innerHTML, data);\n } else if (day.getMonth() === this.viewDate().getMonth()) {\n // currentMonthDate\n dateHTML = Mustache.render(\n this.currentMonthDateTemplateTarget.innerHTML,\n data,\n );\n } else {\n // otherMonthDate\n dateHTML = Mustache.render(\n this.otherMonthDateTemplateTarget.innerHTML,\n data,\n );\n }\n return dateHTML;\n }\n\n monthAndYear() {\n const month = this.viewDate().toLocaleString(\"en-US\", { month: \"long\" });\n const year = this.viewDate().getFullYear();\n return `${month} ${year}`;\n }\n\n selectedDate() {\n return this.parseDate(this.selectedDateValue);\n }\n\n viewDate() {\n return (\n this.parseDate(this.viewDateValue) || this.selectedDate() || new Date()\n );\n }\n\n getFullWeeksStartAndEndInMonth() {\n const month = this.viewDate().getMonth();\n const year = this.viewDate().getFullYear();\n\n let weeks = [],\n firstDate = new Date(year, month, 1),\n lastDate = new Date(year, month + 1, 0),\n numDays = lastDate.getDate();\n\n let start = 1;\n let end;\n if (firstDate.getDay() === 1) {\n end = 7;\n } else if (firstDate.getDay() === 0) {\n let preMonthEndDay = new Date(year, month, 0);\n start = preMonthEndDay.getDate() - 6 + 1;\n end = 1;\n } else {\n let preMonthEndDay = new Date(year, month, 0);\n start = preMonthEndDay.getDate() + 1 - firstDate.getDay() + 1;\n end = 7 - firstDate.getDay() + 1;\n weeks.push({\n start: start,\n end: end,\n });\n start = end + 1;\n end = end + 7;\n }\n while (start <= numDays) {\n weeks.push({\n start: start,\n end: end,\n });\n start = end + 1;\n end = end + 7;\n end = start === 1 && end === 8 ? 1 : end;\n if (end > numDays && start <= numDays) {\n end = end - numDays;\n weeks.push({\n start: start,\n end: end,\n });\n break;\n }\n }\n // *** the magic starts here\n return weeks.map(({ start, end }, index) => {\n const sub = +(start > end && index === 0);\n return Array.from({ length: 7 }, (_, index) => {\n const date = new Date(year, month - sub, start + index);\n return date;\n });\n });\n }\n\n formatDate(date) {\n const format = this.formatValue;\n const day = date.getDate();\n const month = date.getMonth() + 1;\n const year = date.getFullYear();\n const hours = date.getHours();\n const minutes = date.getMinutes();\n const seconds = date.getSeconds();\n const dayOfWeek = date.toLocaleString(\"en-US\", { weekday: \"long\" });\n const monthName = date.toLocaleString(\"en-US\", { month: \"long\" });\n const daySuffix = this.getDaySuffix(day);\n\n const map = {\n yyyy: year,\n MM: (\"0\" + month).slice(-2),\n dd: (\"0\" + day).slice(-2),\n HH: (\"0\" + hours).slice(-2),\n mm: (\"0\" + minutes).slice(-2),\n ss: (\"0\" + seconds).slice(-2),\n EEEE: dayOfWeek,\n MMMM: monthName,\n do: day + daySuffix,\n PPPP: `${dayOfWeek}, ${monthName} ${day}${daySuffix}, ${year}`,\n };\n\n const formattedDate = format.replace(\n /yyyy|MM|dd|HH|mm|ss|EEEE|MMMM|do|PPPP/g,\n (matched) => map[matched],\n );\n return formattedDate;\n }\n\n getDaySuffix(day) {\n if (day > 3 && day < 21) return \"th\";\n switch (day % 10) {\n case 1:\n return \"st\";\n case 2:\n return \"nd\";\n case 3:\n return \"rd\";\n default:\n return \"th\";\n }\n }\n\n minDate() {\n return this.parseDate(this.minDateValue);\n }\n\n isDateDisabled(date) {\n const minDate = this.minDate();\n const candidate = this.parseDate(date);\n\n if (!minDate || !candidate) return false;\n\n return this.startOfDay(candidate) < this.startOfDay(minDate);\n }\n\n parseDate(value) {\n if (!value) return null;\n if (value instanceof Date) return new Date(value);\n\n const isoDate = value.toString().match(/^(\\d{4})-(\\d{2})-(\\d{2})/);\n if (isoDate) {\n return new Date(\n Number(isoDate[1]),\n Number(isoDate[2]) - 1,\n Number(isoDate[3]),\n );\n }\n\n const date = new Date(value);\n return Number.isNaN(date.getTime()) ? null : date;\n }\n\n startOfDay(date) {\n const normalizedDate = new Date(date);\n normalizedDate.setHours(0, 0, 0, 0);\n return normalizedDate;\n }\n}\n" }, { "path": "calendar_days.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CalendarDays < Base\n BASE_CLASS = \"inline-flex items-center justify-center rounded-md text-sm ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-8 w-8 p-0 font-normal aria-selected:opacity-100\"\n\n def view_template\n render_selected_date_template\n render_today_date_template\n render_current_month_date_template\n render_other_month_date_template\n end\n\n private\n\n def render_selected_date_template\n date_template(\"selectedDateTemplate\") do\n button(\n data_day: \"{{day}}\",\n data_action: \"click->ruby-ui--calendar#selectDay\",\n name: \"day\",\n class:\n [\n BASE_CLASS,\n \"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground\"\n ],\n role: \"gridcell\",\n tabindex: \"0\",\n type: \"button\",\n aria_selected: \"true\"\n ) { \"{{dayDate}}\" }\n end\n end\n\n def render_today_date_template\n date_template(\"todayDateTemplate\") do\n button(\n data_day: \"{{day}}\",\n data_action: \"click->ruby-ui--calendar#selectDay\",\n name: \"day\",\n class:\n [\n BASE_CLASS,\n \"bg-accent text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground\"\n ],\n role: \"gridcell\",\n tabindex: \"-1\",\n type: \"button\"\n ) { \"{{dayDate}}\" }\n end\n end\n\n def render_current_month_date_template\n date_template(\"currentMonthDateTemplate\") do\n button(\n data_day: \"{{day}}\",\n data_action: \"click->ruby-ui--calendar#selectDay\",\n name: \"day\",\n class:\n [\n BASE_CLASS,\n \"bg-background text-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground\"\n ],\n role: \"gridcell\",\n tabindex: \"-1\",\n type: \"button\"\n ) { \"{{dayDate}}\" }\n end\n end\n\n def render_other_month_date_template\n date_template(\"otherMonthDateTemplate\") do\n button(\n data_day: \"{{day}}\",\n data_action: \" click->ruby-ui--calendar#selectDay\",\n name: \"day\",\n class:\n [\n BASE_CLASS,\n \"bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground\"\n ],\n role: \"gridcell\",\n tabindex: \"-1\",\n type: \"button\"\n ) { \"{{dayDate}}\" }\n end\n end\n\n def date_template(target, &block)\n template(data: {ruby_ui__calendar_target: target}) do\n td(\n class:\n \"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected])]:rounded-md\",\n role: \"presentation\",\n &block\n )\n end\n end\n\n def default_attrs\n {}\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CalendarDays < Base\n BASE_CLASS = \"inline-flex items-center justify-center rounded-md text-sm ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-8 w-8 p-0 font-normal aria-selected:opacity-100\"\n\n def view_template\n render_disabled_date_template\n render_selected_date_template\n render_today_date_template\n render_current_month_date_template\n render_other_month_date_template\n end\n\n private\n\n def render_disabled_date_template\n date_template(\"disabledDateTemplate\") do\n button(\n data_day: \"{{day}}\",\n name: \"day\",\n class:\n [\n BASE_CLASS,\n \"cursor-not-allowed bg-background text-muted-foreground hover:bg-background hover:text-muted-foreground\"\n ],\n disabled: true,\n role: \"gridcell\",\n tabindex: \"-1\",\n type: \"button\",\n aria_disabled: \"true\"\n ) { \"{{dayDate}}\" }\n end\n end\n\n def render_selected_date_template\n date_template(\"selectedDateTemplate\") do\n button(\n data_day: \"{{day}}\",\n data_action: \"click->ruby-ui--calendar#selectDay\",\n name: \"day\",\n class:\n [\n BASE_CLASS,\n \"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground\"\n ],\n role: \"gridcell\",\n tabindex: \"0\",\n type: \"button\",\n aria_selected: \"true\"\n ) { \"{{dayDate}}\" }\n end\n end\n\n def render_today_date_template\n date_template(\"todayDateTemplate\") do\n button(\n data_day: \"{{day}}\",\n data_action: \"click->ruby-ui--calendar#selectDay\",\n name: \"day\",\n class:\n [\n BASE_CLASS,\n \"bg-accent text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground\"\n ],\n role: \"gridcell\",\n tabindex: \"-1\",\n type: \"button\"\n ) { \"{{dayDate}}\" }\n end\n end\n\n def render_current_month_date_template\n date_template(\"currentMonthDateTemplate\") do\n button(\n data_day: \"{{day}}\",\n data_action: \"click->ruby-ui--calendar#selectDay\",\n name: \"day\",\n class:\n [\n BASE_CLASS,\n \"bg-background text-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground\"\n ],\n role: \"gridcell\",\n tabindex: \"-1\",\n type: \"button\"\n ) { \"{{dayDate}}\" }\n end\n end\n\n def render_other_month_date_template\n date_template(\"otherMonthDateTemplate\") do\n button(\n data_day: \"{{day}}\",\n data_action: \" click->ruby-ui--calendar#selectDay\",\n name: \"day\",\n class:\n [\n BASE_CLASS,\n \"bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground\"\n ],\n role: \"gridcell\",\n tabindex: \"-1\",\n type: \"button\"\n ) { \"{{dayDate}}\" }\n end\n end\n\n def date_template(target, &block)\n template(data: {ruby_ui__calendar_target: target}) do\n td(\n class:\n \"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected])]:rounded-md\",\n role: \"presentation\",\n &block\n )\n end\n end\n\n def default_attrs\n {}\n end\n end\nend\n" }, { "path": "calendar_header.rb", @@ -504,7 +508,7 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Calendar", - "docs_markdown": "# Calendar\n\nA date field component that allows users to enter and edit date.\n\n## Usage\n\n### Connect to input\n\n```ruby\ndiv(class: 'space-y-4') do\n Input(type: 'string', placeholder: \"Select a date\", class: 'rounded-md border shadow', id: 'date', data_controller: 'ruby-ui--calendar-input')\n Calendar(input_id: '#date', class: 'rounded-md border shadow')\nend\n```\n\n### Format date\n\n```ruby\ndiv(class: 'space-y-4') do\n Input(type: 'string', placeholder: \"Select a date\", class: 'rounded-md border shadow', id: 'formatted-date', data_controller: 'ruby-ui--calendar-input')\n Calendar(input_id: '#formatted-date', date_format: 'PPPP', class: 'rounded-md border shadow')\nend\n```", + "docs_markdown": "# Calendar\n\nA date field component that allows users to enter and edit date.\n\n## Usage\n\n### Connect to input\n\n```ruby\ndiv(class: 'space-y-4') do\n Input(type: 'string', placeholder: \"Select a date\", class: 'rounded-md border shadow', id: 'date', data_controller: 'ruby-ui--calendar-input')\n Calendar(input_id: '#date', class: 'rounded-md border shadow')\nend\n```\n\n### Format date\n\n```ruby\ndiv(class: 'space-y-4') do\n Input(type: 'string', placeholder: \"Select a date\", class: 'rounded-md border shadow', id: 'formatted-date', data_controller: 'ruby-ui--calendar-input')\n Calendar(input_id: '#formatted-date', date_format: 'PPPP', class: 'rounded-md border shadow')\nend\n```\n\n### Minimum date\n\n```ruby\ndiv(class: 'space-y-4') do\n Input(type: 'string', placeholder: \"Select a date\", class: 'rounded-md border shadow', id: 'minimum-date', data_controller: 'ruby-ui--calendar-input')\n Calendar(input_id: '#minimum-date', min_date: Date.current, class: 'rounded-md border shadow')\nend\n```", "examples": [ { "title": "Connect to input", @@ -515,6 +519,11 @@ "title": "Format date", "code": "div(class: 'space-y-4') do\n Input(type: 'string', placeholder: \"Select a date\", class: 'rounded-md border shadow', id: 'formatted-date', data_controller: 'ruby-ui--calendar-input')\n Calendar(input_id: '#formatted-date', date_format: 'PPPP', class: 'rounded-md border shadow')\nend\n", "language": "ruby" + }, + { + "title": "Minimum date", + "code": "div(class: 'space-y-4') do\n Input(type: 'string', placeholder: \"Select a date\", class: 'rounded-md border shadow', id: 'minimum-date', data_controller: 'ruby-ui--calendar-input')\n Calendar(input_id: '#minimum-date', min_date: Date.current, class: 'rounded-md border shadow')\nend\n", + "language": "ruby" } ] }, @@ -858,7 +867,7 @@ }, { "path": "combobox_controller.js", - "content": "import { Controller } from \"@hotwired/stimulus\";\nimport { computePosition, autoUpdate, offset, flip } from \"@floating-ui/dom\";\n\n// Connects to data-controller=\"ruby-ui--combobox\"\nexport default class extends Controller {\n static values = {\n term: String\n }\n\n static targets = [\n \"input\",\n \"toggleAll\",\n \"popover\",\n \"item\",\n \"emptyState\",\n \"searchInput\",\n \"trigger\",\n \"triggerContent\"\n ]\n\n selectedItemIndex = null\n\n connect() {\n this.updateTriggerContent()\n }\n\n disconnect() {\n if (this.cleanup) { this.cleanup() }\n }\n\n handlePopoverToggle(event) {\n // Keep ariaExpanded in sync with the actual popover state\n this.triggerTarget.ariaExpanded = event.newState === 'open' ? 'true' : 'false'\n }\n\n inputChanged(e) {\n this.updateTriggerContent()\n\n if (e.target.type == \"radio\") {\n this.closePopover()\n }\n\n if (this.hasToggleAllTarget && !e.target.checked) {\n this.toggleAllTarget.checked = false\n }\n }\n\n inputContent(input) {\n return input.dataset.text || input.parentElement.textContent\n }\n\n toggleAllItems() {\n const isChecked = this.toggleAllTarget.checked\n this.inputTargets.forEach(input => input.checked = isChecked)\n this.updateTriggerContent()\n }\n\n updateTriggerContent() {\n const checkedInputs = this.inputTargets.filter(input => input.checked)\n\n if (checkedInputs.length === 0) {\n this.triggerContentTarget.innerText = this.triggerTarget.dataset.placeholder\n } else if (this.termValue && checkedInputs.length > 1) {\n this.triggerContentTarget.innerText = `${checkedInputs.length} ${this.termValue}`\n } else {\n this.triggerContentTarget.innerText = checkedInputs.map((input) => this.inputContent(input)).join(\", \")\n }\n }\n\n togglePopover(event) {\n event.preventDefault()\n\n if (this.triggerTarget.ariaExpanded === \"true\") {\n this.closePopover()\n } else {\n this.openPopover(event)\n }\n }\n\n openPopover(event) {\n if (event) event.preventDefault()\n\n this.updatePopoverPosition()\n this.updatePopoverWidth()\n this.triggerTarget.ariaExpanded = \"true\"\n this.selectedItemIndex = null\n this.itemTargets.forEach(item => item.ariaCurrent = \"false\")\n this.popoverTarget.showPopover()\n }\n\n closePopover() {\n this.triggerTarget.ariaExpanded = \"false\"\n this.popoverTarget.hidePopover()\n }\n\n filterItems(e) {\n if ([\"ArrowDown\", \"ArrowUp\", \"Tab\", \"Enter\"].includes(e.key)) {\n return\n }\n\n const filterTerm = this.searchInputTarget.value.toLowerCase()\n\n if (this.hasToggleAllTarget) {\n if (filterTerm) this.toggleAllTarget.parentElement.classList.add(\"hidden\")\n else this.toggleAllTarget.parentElement.classList.remove(\"hidden\")\n }\n\n let resultCount = 0\n\n this.selectedItemIndex = null\n\n this.inputTargets.forEach((input) => {\n const text = this.inputContent(input).toLowerCase()\n\n if (text.indexOf(filterTerm) > -1) {\n input.parentElement.classList.remove(\"hidden\")\n resultCount++\n } else {\n input.parentElement.classList.add(\"hidden\")\n }\n })\n\n this.emptyStateTarget.classList.toggle(\"hidden\", resultCount !== 0)\n }\n\n keyDownPressed() {\n if (this.selectedItemIndex !== null) {\n this.selectedItemIndex++\n } else {\n this.selectedItemIndex = 0\n }\n\n this.focusSelectedInput()\n }\n\n keyUpPressed() {\n if (this.selectedItemIndex !== null) {\n this.selectedItemIndex--\n } else {\n this.selectedItemIndex = -1\n }\n\n this.focusSelectedInput()\n }\n\n focusSelectedInput() {\n const visibleInputs = this.inputTargets.filter(input => !input.parentElement.classList.contains(\"hidden\"))\n\n this.wrapSelectedInputIndex(visibleInputs.length)\n\n visibleInputs.forEach((input, index) => {\n if (index == this.selectedItemIndex) {\n input.parentElement.ariaCurrent = \"true\"\n input.parentElement.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })\n } else {\n input.parentElement.ariaCurrent = \"false\"\n }\n })\n }\n\n keyEnterPressed(event) {\n event.preventDefault()\n const option = this.itemTargets.find(item => item.ariaCurrent === \"true\")\n\n if (option) {\n option.click()\n }\n }\n\n wrapSelectedInputIndex(length) {\n this.selectedItemIndex = ((this.selectedItemIndex % length) + length) % length\n }\n\n updatePopoverPosition() {\n this.cleanup = autoUpdate(this.triggerTarget, this.popoverTarget, () => {\n computePosition(this.triggerTarget, this.popoverTarget, {\n placement: 'bottom-start',\n middleware: [offset(4), flip()],\n }).then(({ x, y }) => {\n Object.assign(this.popoverTarget.style, {\n left: `${x}px`,\n top: `${y}px`,\n });\n });\n });\n }\n\n updatePopoverWidth() {\n this.popoverTarget.style.width = `${this.triggerTarget.offsetWidth}px`\n }\n}\n" + "content": "import { Controller } from \"@hotwired/stimulus\";\nimport { computePosition, autoUpdate, offset, flip } from \"@floating-ui/dom\";\n\n// Connects to data-controller=\"ruby-ui--combobox\"\nexport default class extends Controller {\n static values = {\n term: String,\n minPopoverWidth: { type: Number, default: 240 }\n }\n\n static targets = [\n \"input\",\n \"toggleAll\",\n \"popover\",\n \"item\",\n \"emptyState\",\n \"searchInput\",\n \"trigger\",\n \"triggerContent\"\n ]\n\n selectedItemIndex = null\n\n connect() {\n this.updateTriggerContent()\n }\n\n disconnect() {\n if (this.cleanup) { this.cleanup() }\n }\n\n handlePopoverToggle(event) {\n // Keep ariaExpanded in sync with the actual popover state\n this.triggerTarget.ariaExpanded = event.newState === 'open' ? 'true' : 'false'\n }\n\n inputChanged(e) {\n this.updateTriggerContent()\n\n if (e.target.type == \"radio\") {\n this.closePopover()\n }\n\n if (this.hasToggleAllTarget && !e.target.checked) {\n this.toggleAllTarget.checked = false\n }\n }\n\n inputContent(input) {\n return input.dataset.text || input.parentElement.textContent\n }\n\n toggleAllItems() {\n const isChecked = this.toggleAllTarget.checked\n this.inputTargets.forEach(input => input.checked = isChecked)\n this.updateTriggerContent()\n }\n\n updateTriggerContent() {\n const checkedInputs = this.inputTargets.filter(input => input.checked)\n\n if (checkedInputs.length === 0) {\n this.triggerContentTarget.innerText = this.triggerTarget.dataset.placeholder\n } else if (this.termValue && checkedInputs.length > 1) {\n this.triggerContentTarget.innerText = `${checkedInputs.length} ${this.termValue}`\n } else {\n this.triggerContentTarget.innerText = checkedInputs.map((input) => this.inputContent(input)).join(\", \")\n }\n }\n\n togglePopover(event) {\n event.preventDefault()\n\n if (this.triggerTarget.ariaExpanded === \"true\") {\n this.closePopover()\n } else {\n this.openPopover(event)\n }\n }\n\n openPopover(event) {\n if (event) event.preventDefault()\n\n this.updatePopoverPosition()\n this.updatePopoverWidth()\n this.triggerTarget.ariaExpanded = \"true\"\n this.selectedItemIndex = null\n this.itemTargets.forEach(item => item.ariaCurrent = \"false\")\n this.popoverTarget.showPopover()\n }\n\n closePopover() {\n this.triggerTarget.ariaExpanded = \"false\"\n this.popoverTarget.hidePopover()\n }\n\n filterItems(e) {\n if ([\"ArrowDown\", \"ArrowUp\", \"Tab\", \"Enter\"].includes(e.key)) {\n return\n }\n\n const filterTerm = this.searchInputTarget.value.toLowerCase()\n\n if (this.hasToggleAllTarget) {\n if (filterTerm) this.toggleAllTarget.parentElement.classList.add(\"hidden\")\n else this.toggleAllTarget.parentElement.classList.remove(\"hidden\")\n }\n\n let resultCount = 0\n\n this.selectedItemIndex = null\n\n this.inputTargets.forEach((input) => {\n const text = this.inputContent(input).toLowerCase()\n\n if (text.indexOf(filterTerm) > -1) {\n input.parentElement.classList.remove(\"hidden\")\n resultCount++\n } else {\n input.parentElement.classList.add(\"hidden\")\n }\n })\n\n this.emptyStateTarget.classList.toggle(\"hidden\", resultCount !== 0)\n }\n\n keyDownPressed() {\n if (this.selectedItemIndex !== null) {\n this.selectedItemIndex++\n } else {\n this.selectedItemIndex = 0\n }\n\n this.focusSelectedInput()\n }\n\n keyUpPressed() {\n if (this.selectedItemIndex !== null) {\n this.selectedItemIndex--\n } else {\n this.selectedItemIndex = -1\n }\n\n this.focusSelectedInput()\n }\n\n focusSelectedInput() {\n const visibleInputs = this.inputTargets.filter(input => !input.parentElement.classList.contains(\"hidden\"))\n\n this.wrapSelectedInputIndex(visibleInputs.length)\n\n visibleInputs.forEach((input, index) => {\n if (index == this.selectedItemIndex) {\n input.parentElement.ariaCurrent = \"true\"\n input.parentElement.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })\n } else {\n input.parentElement.ariaCurrent = \"false\"\n }\n })\n }\n\n keyEnterPressed(event) {\n event.preventDefault()\n const option = this.itemTargets.find(item => item.ariaCurrent === \"true\")\n\n if (option) {\n option.click()\n }\n }\n\n wrapSelectedInputIndex(length) {\n this.selectedItemIndex = ((this.selectedItemIndex % length) + length) % length\n }\n\n updatePopoverPosition() {\n this.cleanup = autoUpdate(this.triggerTarget, this.popoverTarget, () => {\n computePosition(this.triggerTarget, this.popoverTarget, {\n placement: 'bottom-start',\n middleware: [offset(4), flip()],\n }).then(({ x, y }) => {\n Object.assign(this.popoverTarget.style, {\n left: `${x}px`,\n top: `${y}px`,\n });\n });\n });\n }\n\n updatePopoverWidth() {\n const width = Math.max(this.triggerTarget.offsetWidth, this.minPopoverWidthValue)\n this.popoverTarget.style.width = `${width}px`\n }\n}\n" }, { "path": "combobox_empty_state.rb", @@ -967,19 +976,23 @@ }, { "path": "command_controller.js", - "content": "import { Controller } from \"@hotwired/stimulus\";\nimport Fuse from \"fuse.js\";\n\n// Connects to data-controller=\"ruby-ui--command\"\nexport default class extends Controller {\n static targets = [\"input\", \"group\", \"item\", \"empty\", \"content\"];\n\n static values = {\n open: {\n type: Boolean,\n default: false,\n },\n };\n\n connect() {\n this.selectedIndex = -1;\n\n if (!this.hasInputTarget) {\n return;\n }\n\n this.inputTarget.focus();\n this.searchIndex = this.buildSearchIndex();\n this.toggleVisibility(this.emptyTargets, false);\n\n if (this.openValue && this.hasContentTarget) {\n this.open();\n }\n }\n\n open(e) {\n if (e) {\n e.preventDefault();\n }\n\n if (!this.hasContentTarget) {\n return;\n }\n\n document.body.insertAdjacentHTML(\"beforeend\", this.contentTarget.innerHTML);\n // prevent scroll on body\n document.body.classList.add(\"overflow-hidden\");\n }\n\n dismiss() {\n // allow scroll on body\n document.body.classList.remove(\"overflow-hidden\");\n // remove the element\n this.element.remove();\n }\n\n filter(e) {\n // Deselect any previously selected item\n this.deselectAll();\n\n const query = e.target.value.toLowerCase();\n if (query.length === 0) {\n this.resetVisibility();\n return;\n }\n\n this.toggleVisibility(this.itemTargets, false);\n\n const results = this.searchIndex.search(query);\n results.forEach((result) =>\n this.toggleVisibility([result.item.element], true),\n );\n\n this.toggleVisibility(this.emptyTargets, results.length === 0);\n this.updateGroupVisibility();\n }\n\n toggleVisibility(elements, isVisible) {\n elements.forEach((el) => el.classList.toggle(\"hidden\", !isVisible));\n }\n\n updateGroupVisibility() {\n this.groupTargets.forEach((group) => {\n const hasVisibleItems =\n group.querySelectorAll(\n \"[data-ruby-ui--command-target='item']:not(.hidden)\",\n ).length > 0;\n this.toggleVisibility([group], hasVisibleItems);\n });\n }\n\n resetVisibility() {\n this.toggleVisibility(this.itemTargets, true);\n this.toggleVisibility(this.groupTargets, true);\n this.toggleVisibility(this.emptyTargets, false);\n }\n\n buildSearchIndex() {\n const options = {\n keys: [\"value\"],\n threshold: 0.2,\n includeMatches: true,\n };\n const items = this.itemTargets.map((el) => ({\n value: el.dataset.value,\n element: el,\n }));\n return new Fuse(items, options);\n }\n\n handleKeydown(e) {\n const visibleItems = this.itemTargets.filter(\n (item) => !item.classList.contains(\"hidden\"),\n );\n if (e.key === \"ArrowDown\") {\n e.preventDefault();\n this.updateSelectedItem(visibleItems, 1);\n } else if (e.key === \"ArrowUp\") {\n e.preventDefault();\n this.updateSelectedItem(visibleItems, -1);\n } else if (e.key === \"Enter\" && this.selectedIndex !== -1) {\n e.preventDefault();\n visibleItems[this.selectedIndex].click();\n }\n }\n\n updateSelectedItem(visibleItems, direction) {\n if (this.selectedIndex >= 0) {\n this.toggleAriaSelected(visibleItems[this.selectedIndex], false);\n }\n\n this.selectedIndex += direction;\n\n // Ensure the selected index is within the bounds of the visible items\n if (this.selectedIndex < 0) {\n this.selectedIndex = visibleItems.length - 1;\n } else if (this.selectedIndex >= visibleItems.length) {\n this.selectedIndex = 0;\n }\n\n this.toggleAriaSelected(visibleItems[this.selectedIndex], true);\n }\n\n toggleAriaSelected(element, isSelected) {\n element.setAttribute(\"aria-selected\", isSelected.toString());\n }\n\n deselectAll() {\n this.itemTargets.forEach((item) => this.toggleAriaSelected(item, false));\n this.selectedIndex = -1;\n }\n}\n" + "content": "import { Controller } from \"@hotwired/stimulus\";\nimport Fuse from \"fuse.js\";\n\n// Connects to data-controller=\"ruby-ui--command\"\nexport default class extends Controller {\n static targets = [\"input\", \"group\", \"item\", \"empty\"];\n\n connect() {\n this.selectedIndex = -1;\n\n if (!this.hasInputTarget) {\n return;\n }\n\n this.inputTarget.focus();\n this.searchIndex = this.buildSearchIndex();\n this.toggleVisibility(this.emptyTargets, false);\n }\n\n dismiss() {\n // allow scroll on body\n document.body.classList.remove(\"overflow-hidden\");\n // remove the element\n this.element.remove();\n }\n\n focusInput() {\n this.inputTarget?.focus();\n }\n\n filter(e) {\n // Deselect any previously selected item\n this.deselectAll();\n\n const query = e.target.value.toLowerCase();\n if (query.length === 0) {\n this.resetVisibility();\n return;\n }\n\n this.toggleVisibility(this.itemTargets, false);\n\n const results = this.searchIndex.search(query);\n results.forEach((result) =>\n this.toggleVisibility([result.item.element], true),\n );\n\n this.toggleVisibility(this.emptyTargets, results.length === 0);\n this.updateGroupVisibility();\n }\n\n toggleVisibility(elements, isVisible) {\n elements.forEach((el) => el.classList.toggle(\"hidden\", !isVisible));\n }\n\n updateGroupVisibility() {\n this.groupTargets.forEach((group) => {\n const hasVisibleItems =\n group.querySelectorAll(\n \"[data-ruby-ui--command-target='item']:not(.hidden)\",\n ).length > 0;\n this.toggleVisibility([group], hasVisibleItems);\n });\n }\n\n resetVisibility() {\n this.toggleVisibility(this.itemTargets, true);\n this.toggleVisibility(this.groupTargets, true);\n this.toggleVisibility(this.emptyTargets, false);\n }\n\n buildSearchIndex() {\n const options = {\n keys: [\"value\"],\n threshold: 0.2,\n includeMatches: true,\n };\n const items = this.itemTargets.map((el) => ({\n value: el.dataset.value,\n element: el,\n }));\n return new Fuse(items, options);\n }\n\n handleKeydown(e) {\n const visibleItems = this.itemTargets.filter(\n (item) => !item.classList.contains(\"hidden\"),\n );\n if (e.key === \"ArrowDown\") {\n e.preventDefault();\n this.updateSelectedItem(visibleItems, 1);\n } else if (e.key === \"ArrowUp\") {\n e.preventDefault();\n this.updateSelectedItem(visibleItems, -1);\n } else if (e.key === \"Enter\" && this.selectedIndex !== -1) {\n e.preventDefault();\n visibleItems[this.selectedIndex].click();\n }\n }\n\n updateSelectedItem(visibleItems, direction) {\n if (this.selectedIndex >= 0) {\n this.toggleAriaSelected(visibleItems[this.selectedIndex], false);\n }\n\n this.selectedIndex += direction;\n\n // Ensure the selected index is within the bounds of the visible items\n if (this.selectedIndex < 0) {\n this.selectedIndex = visibleItems.length - 1;\n } else if (this.selectedIndex >= visibleItems.length) {\n this.selectedIndex = 0;\n }\n\n this.toggleAriaSelected(visibleItems[this.selectedIndex], true);\n }\n\n toggleAriaSelected(element, isSelected) {\n element.setAttribute(\"aria-selected\", isSelected.toString());\n }\n\n deselectAll() {\n this.itemTargets.forEach((item) => this.toggleAriaSelected(item, false));\n this.selectedIndex = -1;\n }\n}\n" }, { "path": "command_dialog.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CommandDialog < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {controller: \"ruby-ui--command\"}\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CommandDialog < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n controller: \"ruby-ui--command-dialog\",\n ruby_ui__command_dialog_ruby_ui__command_outlet: \"[data-ruby-ui--command-dialog-instance]\"\n }\n }\n end\n end\nend\n" }, { "path": "command_dialog_content.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CommandDialogContent < Base\n SIZES = {\n xs: \"max-w-sm\",\n sm: \"max-w-md\",\n md: \"max-w-lg\",\n lg: \"max-w-2xl\",\n xl: \"max-w-4xl\",\n full: \"max-w-full\"\n }\n\n def initialize(size: :md, **attrs)\n @size = size\n super(**attrs)\n end\n\n def view_template(&block)\n template(data: {ruby_ui__command_target: \"content\"}) do\n div(data: {controller: \"ruby-ui--command\"}) do\n backdrop\n div(**attrs, &block)\n end\n end\n end\n\n private\n\n def default_attrs\n {\n data_state: \"open\",\n class: [\n \"fixed pointer-events-auto left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:w-full\",\n SIZES[@size]\n ]\n }\n end\n\n def backdrop\n div(\n data_state: \"open\",\n data_action: \"click->ruby-ui--command#dismiss esc->ruby-ui--command#dismiss\",\n class: \"fixed pointer-events-auto inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\"\n )\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CommandDialogContent < Base\n SIZES = {\n xs: \"max-w-sm\",\n sm: \"max-w-md\",\n md: \"max-w-lg\",\n lg: \"max-w-2xl\",\n xl: \"max-w-4xl\",\n full: \"max-w-full\"\n }\n\n def initialize(size: :md, **attrs)\n @size = size\n super(**attrs)\n end\n\n def view_template(&block)\n template(data: {ruby_ui__command_dialog_target: \"content\"}) do\n div(data: {controller: \"ruby-ui--command\", ruby_ui__command_dialog_instance: true}) do\n backdrop\n div(**attrs, &block)\n end\n end\n end\n\n private\n\n def default_attrs\n {\n data_state: \"open\",\n class: [\n \"fixed pointer-events-auto left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:w-full\",\n SIZES[@size]\n ]\n }\n end\n\n def backdrop\n div(\n data_state: \"open\",\n data_action: \"click->ruby-ui--command#dismiss esc->ruby-ui--command#dismiss\",\n class: \"fixed pointer-events-auto inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\"\n )\n end\n end\nend\n" + }, + { + "path": "command_dialog_controller.js", + "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"ruby-ui--command-dialog\"\nexport default class extends Controller {\n static targets = [\"content\"];\n static outlets = [\"ruby-ui--command\"];\n\n rubyUiCommandOutletConnected(controller) {\n this.openOutlet = controller;\n }\n\n rubyUiCommandOutletDisconnected() {\n this.openOutlet = null;\n }\n\n open(e) {\n if (e) {\n e.preventDefault();\n }\n\n if (!this.hasContentTarget) {\n return;\n }\n\n if (this.openOutlet) {\n this.openOutlet.focusInput();\n return;\n }\n\n document.body.insertAdjacentHTML(\"beforeend\", this.contentTarget.innerHTML);\n // prevent scroll on body\n document.body.classList.add(\"overflow-hidden\");\n }\n}\n" }, { "path": "command_dialog_trigger.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CommandDialogTrigger < Base\n DEFAULT_KEYBINDINGS = [\n \"keydown.ctrl+k@window\",\n \"keydown.meta+k@window\"\n ].freeze\n\n def initialize(keybindings: DEFAULT_KEYBINDINGS, **attrs)\n @keybindings = keybindings.map { |kb| \"#{kb}->ruby-ui--command#open\" }\n super(**attrs)\n end\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n action: [\"click->ruby-ui--command#open\", @keybindings.join(\" \")]\n }\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CommandDialogTrigger < Base\n DEFAULT_KEYBINDINGS = [\n \"keydown.ctrl+k@window\",\n \"keydown.meta+k@window\"\n ].freeze\n\n def initialize(keybindings: DEFAULT_KEYBINDINGS, **attrs)\n @keybindings = keybindings.map { |kb| \"#{kb}->ruby-ui--command-dialog#open\" }\n super(**attrs)\n end\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n action: [\"click->ruby-ui--command-dialog#open\", @keybindings.join(\" \")]\n }\n }\n end\n end\nend\n" }, { "path": "command_empty.rb", @@ -1935,7 +1948,7 @@ "files": [ { "path": "sheet.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Sheet < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {controller: \"ruby-ui--sheet\"}\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Sheet < Base\n def initialize(open: false, **attrs)\n @open = open\n super(**attrs)\n end\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n controller: \"ruby-ui--sheet\",\n ruby_ui__sheet_open_value: @open.to_s\n }\n }\n end\n end\nend\n" }, { "path": "sheet_content.rb", @@ -1947,7 +1960,7 @@ }, { "path": "sheet_controller.js", - "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n static targets = [\"content\"]\n\n open() {\n document.body.insertAdjacentHTML(\"beforeend\", this.contentTarget.innerHTML)\n }\n}\n" + "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n static targets = [\"content\"]\n\n static values = { open: false }\n\n connect() {\n if (this.openValue) this.open()\n }\n\n open() {\n document.body.insertAdjacentHTML(\"beforeend\", this.contentTarget.innerHTML)\n }\n}\n" }, { "path": "sheet_description.rb", @@ -2369,43 +2382,145 @@ "name": "ThemeToggle", "description": "Toggle between dark/light theme.", "files": [ - { - "path": "set_dark_mode.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SetDarkMode < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n def default_attrs\n {\n class: \"hidden dark:inline-block\",\n data: {controller: \"ruby-ui--theme-toggle\", action: \"click->ruby-ui--theme-toggle#setLightTheme\"}\n }\n end\n end\nend\n" - }, - { - "path": "set_light_mode.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SetLightMode < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n def default_attrs\n {\n class: \"dark:hidden\",\n data: {controller: \"ruby-ui--theme-toggle\", action: \"click->ruby-ui--theme-toggle#setDarkTheme\"}\n }\n end\n end\nend\n" - }, { "path": "theme_toggle.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ThemeToggle < Base\n def view_template(&)\n div(**attrs, &)\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ThemeToggle < Base\n def view_template(&block)\n RubyUI.Toggle(\n variant: :default,\n size: :default,\n aria: {label: \"Toggle theme\"},\n wrapper: {\n data: {\n controller: \"ruby-ui--theme-toggle\",\n action: \"ruby-ui--toggle:change->ruby-ui--theme-toggle#apply\"\n }\n },\n **attrs,\n &block\n )\n end\n end\nend\n" }, { "path": "theme_toggle_controller.js", - "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n initialize() {\n this.setTheme()\n }\n\n setTheme() {\n // On page load or when changing themes, best to add inline in `head` to avoid FOUC\n if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {\n document.documentElement.classList.add('dark')\n document.documentElement.classList.remove('light')\n } else {\n document.documentElement.classList.remove('dark')\n document.documentElement.classList.add('light')\n }\n }\n\n setLightTheme() {\n // Whenever the user explicitly chooses light mode\n localStorage.theme = 'light'\n this.setTheme()\n }\n\n setDarkTheme() {\n // Whenever the user explicitly chooses dark mode\n localStorage.theme = 'dark'\n this.setTheme()\n }\n}\n" + "content": "import { Controller } from \"@hotwired/stimulus\"\n\n// Connects to data-controller=\"ruby-ui--theme-toggle\"\n// Sits on the same wrapper as ruby-ui--toggle. Listens for the toggle's\n// ruby-ui--toggle:change event. pressed = dark mode.\nexport default class extends Controller {\n connect() {\n this.applyTheme(this.currentTheme())\n }\n\n apply(event) {\n const pressed = event.detail?.pressed\n const theme = pressed ? \"dark\" : \"light\"\n localStorage.theme = theme\n this.applyTheme(theme)\n }\n\n currentTheme() {\n if (localStorage.theme === \"dark\") return \"dark\"\n if (localStorage.theme === \"light\") return \"light\"\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches ? \"dark\" : \"light\"\n }\n\n applyTheme(theme) {\n const html = document.documentElement\n if (theme === \"dark\") {\n html.classList.add(\"dark\")\n html.classList.remove(\"light\")\n } else {\n html.classList.add(\"light\")\n html.classList.remove(\"dark\")\n }\n // Flip the sibling Toggle controller's pressed value; it will propagate\n // aria-pressed / data-state to the button target.\n const dark = theme === \"dark\"\n this.element.setAttribute(\"data-ruby-ui--toggle-pressed-value\", dark ? \"true\" : \"false\")\n }\n}\n" } ], "dependencies": { - "components": [], + "components": [ + "Toggle" + ], "js_packages": [], "gems": [] }, "install_command": "rails g ruby_ui:component ThemeToggle", - "docs_markdown": "# Theme Toggle\n\nToggle between dark/light theme.\n\n## Usage\n\n### With icon\n\n```ruby\nThemeToggle do |toggle|\n SetLightMode do\n Button(variant: :ghost, icon: true) do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n viewbox: \"0 0 24 24\",\n fill: \"currentColor\",\n class: \"w-4 h-4\"\n ) do |s|\n s.path(\n d:\n \"M12 2.25a.75.75 0 01.75.75v2.25a.75.75 0 01-1.5 0V3a.75.75 0 01.75-.75zM7.5 12a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM18.894 6.166a.75.75 0 00-1.06-1.06l-1.591 1.59a.75.75 0 101.06 1.061l1.591-1.59zM21.75 12a.75.75 0 01-.75.75h-2.25a.75.75 0 010-1.5H21a.75.75 0 01.75.75zM17.834 18.894a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 10-1.061 1.06l1.59 1.591zM12 18a.75.75 0 01.75.75V21a.75.75 0 01-1.5 0v-2.25A.75.75 0 0112 18zM7.758 17.303a.75.75 0 00-1.061-1.06l-1.591 1.59a.75.75 0 001.06 1.061l1.591-1.59zM6 12a.75.75 0 01-.75.75H3a.75.75 0 010-1.5h2.25A.75.75 0 016 12zM6.697 7.757a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 00-1.061 1.06l1.59 1.591z\"\n )\n end\n end\n end\n\n SetDarkMode do\n Button(variant: :ghost, icon: true) do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n viewbox: \"0 0 24 24\",\n fill: \"currentColor\",\n class: \"w-4 h-4\"\n ) do |s|\n s.path(\n fill_rule: \"evenodd\",\n d:\n \"M9.528 1.718a.75.75 0 01.162.819A8.97 8.97 0 009 6a9 9 0 009 9 8.97 8.97 0 003.463-.69.75.75 0 01.981.98 10.503 10.503 0 01-9.694 6.46c-5.799 0-10.5-4.701-10.5-10.5 0-4.368 2.667-8.112 6.46-9.694a.75.75 0 01.818.162z\",\n clip_rule: \"evenodd\"\n )\n end\n end\n end\nend\n```\n\n### With text\n\n```ruby\nThemeToggle do |toggle|\n SetLightMode do\n Button(variant: :primary) { \"Light\" }\n end\n\n SetDarkMode do\n Button(variant: :primary) { \"Dark\" }\n end\nend\n```", + "docs_markdown": "# Theme Toggle\n\nToggle between dark/light theme.\n\n## Usage\n\n### With icon\n\n```ruby\nThemeToggle do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n viewbox: \"0 0 24 24\",\n fill: \"currentColor\",\n class: \"w-4 h-4\"\n ) do |s|\n s.path(\n d:\n \"M12 2.25a.75.75 0 01.75.75v2.25a.75.75 0 01-1.5 0V3a.75.75 0 01.75-.75zM7.5 12a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM18.894 6.166a.75.75 0 00-1.06-1.06l-1.591 1.59a.75.75 0 101.06 1.061l1.591-1.59zM21.75 12a.75.75 0 01-.75.75h-2.25a.75.75 0 010-1.5H21a.75.75 0 01.75.75zM17.834 18.894a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 10-1.061 1.06l1.59 1.591zM12 18a.75.75 0 01.75.75V21a.75.75 0 01-1.5 0v-2.25A.75.75 0 0112 18zM7.758 17.303a.75.75 0 00-1.061-1.06l-1.591 1.59a.75.75 0 001.06 1.061l1.591-1.59zM6 12a.75.75 0 01-.75.75H3a.75.75 0 010-1.5h2.25A.75.75 0 016 12zM6.697 7.757a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 00-1.061 1.06l1.59 1.591z\"\n )\n end\nend\n```\n\n### With text\n\n```ruby\nThemeToggle { \"Toggle Theme\" }\n```", "examples": [ { "title": "With icon", - "code": "ThemeToggle do |toggle|\n SetLightMode do\n Button(variant: :ghost, icon: true) do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n viewbox: \"0 0 24 24\",\n fill: \"currentColor\",\n class: \"w-4 h-4\"\n ) do |s|\n s.path(\n d:\n \"M12 2.25a.75.75 0 01.75.75v2.25a.75.75 0 01-1.5 0V3a.75.75 0 01.75-.75zM7.5 12a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM18.894 6.166a.75.75 0 00-1.06-1.06l-1.591 1.59a.75.75 0 101.06 1.061l1.591-1.59zM21.75 12a.75.75 0 01-.75.75h-2.25a.75.75 0 010-1.5H21a.75.75 0 01.75.75zM17.834 18.894a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 10-1.061 1.06l1.59 1.591zM12 18a.75.75 0 01.75.75V21a.75.75 0 01-1.5 0v-2.25A.75.75 0 0112 18zM7.758 17.303a.75.75 0 00-1.061-1.06l-1.591 1.59a.75.75 0 001.06 1.061l1.591-1.59zM6 12a.75.75 0 01-.75.75H3a.75.75 0 010-1.5h2.25A.75.75 0 016 12zM6.697 7.757a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 00-1.061 1.06l1.59 1.591z\"\n )\n end\n end\n end\n\n SetDarkMode do\n Button(variant: :ghost, icon: true) do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n viewbox: \"0 0 24 24\",\n fill: \"currentColor\",\n class: \"w-4 h-4\"\n ) do |s|\n s.path(\n fill_rule: \"evenodd\",\n d:\n \"M9.528 1.718a.75.75 0 01.162.819A8.97 8.97 0 009 6a9 9 0 009 9 8.97 8.97 0 003.463-.69.75.75 0 01.981.98 10.503 10.503 0 01-9.694 6.46c-5.799 0-10.5-4.701-10.5-10.5 0-4.368 2.667-8.112 6.46-9.694a.75.75 0 01.818.162z\",\n clip_rule: \"evenodd\"\n )\n end\n end\n end\nend\n", + "code": "ThemeToggle do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n viewbox: \"0 0 24 24\",\n fill: \"currentColor\",\n class: \"w-4 h-4\"\n ) do |s|\n s.path(\n d:\n \"M12 2.25a.75.75 0 01.75.75v2.25a.75.75 0 01-1.5 0V3a.75.75 0 01.75-.75zM7.5 12a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM18.894 6.166a.75.75 0 00-1.06-1.06l-1.591 1.59a.75.75 0 101.06 1.061l1.591-1.59zM21.75 12a.75.75 0 01-.75.75h-2.25a.75.75 0 010-1.5H21a.75.75 0 01.75.75zM17.834 18.894a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 10-1.061 1.06l1.59 1.591zM12 18a.75.75 0 01.75.75V21a.75.75 0 01-1.5 0v-2.25A.75.75 0 0112 18zM7.758 17.303a.75.75 0 00-1.061-1.06l-1.591 1.59a.75.75 0 001.06 1.061l1.591-1.59zM6 12a.75.75 0 01-.75.75H3a.75.75 0 010-1.5h2.25A.75.75 0 016 12zM6.697 7.757a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 00-1.061 1.06l1.59 1.591z\"\n )\n end\nend\n", "language": "ruby" }, { "title": "With text", - "code": "ThemeToggle do |toggle|\n SetLightMode do\n Button(variant: :primary) { \"Light\" }\n end\n\n SetDarkMode do\n Button(variant: :primary) { \"Dark\" }\n end\nend\n", + "code": "ThemeToggle { \"Toggle Theme\" }\n", "language": "ruby" } ] }, + "toast": { + "name": "Toast", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "toast.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n module Toast\n FLASH_VARIANTS = {\n \"notice\" => :info,\n \"alert\" => :warning,\n \"success\" => :success,\n \"error\" => :error,\n \"warning\" => :warning,\n \"info\" => :info\n }.freeze\n\n def self.flash_variant(key)\n FLASH_VARIANTS[key.to_s] || :default\n end\n end\nend\n" + }, + { + "path": "toast_action.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ToastAction < Base\n def initialize(label:, on: nil, **attrs)\n @label = label\n @on = on\n super(**attrs)\n end\n\n def view_template\n button(**attrs) { @label }\n end\n\n private\n\n def default_attrs\n data = {slot: \"action\"}\n data[:action] = @on if @on\n {\n type: \"button\",\n data: data,\n class: \"inline-flex h-6 shrink-0 cursor-pointer items-center justify-center rounded px-2 text-xs font-medium bg-foreground text-background border-0 ml-auto hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-ring transition-opacity disabled:pointer-events-none disabled:opacity-50\"\n }\n end\n end\nend\n" + }, + { + "path": "toast_cancel.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ToastCancel < Base\n def initialize(label:, **attrs)\n @label = label\n super(**attrs)\n end\n\n def view_template\n button(**attrs) { @label }\n end\n\n private\n\n def default_attrs\n {\n type: \"button\",\n data: {\n slot: \"cancel\",\n action: \"click->ruby-ui--toast#dismiss\"\n },\n class: \"inline-flex h-6 shrink-0 cursor-pointer items-center justify-center rounded px-2 text-xs font-medium bg-foreground/10 text-foreground border-0 ml-auto hover:bg-foreground/15 focus:outline-none focus:ring-2 focus:ring-ring transition-colors\"\n }\n end\n end\nend\n" + }, + { + "path": "toast_close.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ToastClose < Base\n def view_template\n button(**attrs) do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n width: \"14\",\n height: \"14\",\n viewbox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n class: \"size-3.5\"\n ) do |s|\n s.path(d: \"M18 6 6 18\")\n s.path(d: \"m6 6 12 12\")\n end\n span(class: \"sr-only\") { \"Close\" }\n end\n end\n\n private\n\n def default_attrs\n {\n type: \"button\",\n aria_label: \"Close toast\",\n data: {\n slot: \"close\",\n action: \"click->ruby-ui--toast#dismiss\"\n },\n class: \"absolute right-2 top-2 size-6 cursor-pointer rounded-md text-foreground/60 p-0 flex items-center justify-center transition-colors hover:bg-muted hover:text-foreground focus:outline-none focus:ring-1 focus:ring-ring\"\n }\n end\n end\nend\n" + }, + { + "path": "toast_controller.js", + "content": "import { Controller } from \"@hotwired/stimulus\"\n\nconst SWIPE_THRESHOLD = 45\nconst TIME_BEFORE_UNMOUNT = 200\n\n// Connects to data-controller=\"ruby-ui--toast\"\nexport default class extends Controller {\n static values = {\n duration: { type: Number, default: 4000 },\n dismissible: { type: Boolean, default: true },\n invert: { type: Boolean, default: false },\n onDismiss: String,\n onAutoClose: String,\n }\n\n connect() {\n this._timer = null\n this._startedAt = 0\n this._remaining = this.durationValue\n this._paused = false\n this._swipe = { active: false, x: 0, y: 0, startedAt: 0 }\n\n this._onPointerDown = this._onPointerDown.bind(this)\n this._onPointerMove = this._onPointerMove.bind(this)\n this._onPointerUp = this._onPointerUp.bind(this)\n this._onPointerEnter = () => this._pause()\n this._onPointerLeave = () => { if (!this._swipe.active) this._resume() }\n this._onKeyDown = this._onKeyDown.bind(this)\n this._onForceDismiss = (e) => { e.stopPropagation(); this._close() }\n this._onRestart = () => this._restart()\n this._onRegionPause = () => this._pause()\n this._onRegionResume = () => this._resume()\n\n this.element.addEventListener(\"pointerdown\", this._onPointerDown)\n this.element.addEventListener(\"pointerenter\", this._onPointerEnter)\n this.element.addEventListener(\"pointerleave\", this._onPointerLeave)\n this.element.addEventListener(\"keydown\", this._onKeyDown)\n this.element.addEventListener(\"ruby-ui:toast:force-dismiss\", this._onForceDismiss)\n this.element.addEventListener(\"ruby-ui:toast:restart\", this._onRestart)\n document.addEventListener(\"ruby-ui:toast:pause\", this._onRegionPause)\n document.addEventListener(\"ruby-ui:toast:resume\", this._onRegionResume)\n\n requestAnimationFrame(() => {\n this.element.dataset.state = \"open\"\n this._start()\n })\n }\n\n disconnect() {\n this._clearTimer()\n this.element.removeEventListener(\"pointerdown\", this._onPointerDown)\n this.element.removeEventListener(\"pointerenter\", this._onPointerEnter)\n this.element.removeEventListener(\"pointerleave\", this._onPointerLeave)\n this.element.removeEventListener(\"keydown\", this._onKeyDown)\n this.element.removeEventListener(\"ruby-ui:toast:force-dismiss\", this._onForceDismiss)\n this.element.removeEventListener(\"ruby-ui:toast:restart\", this._onRestart)\n document.removeEventListener(\"ruby-ui:toast:pause\", this._onRegionPause)\n document.removeEventListener(\"ruby-ui:toast:resume\", this._onRegionResume)\n }\n\n dismiss(e) {\n e?.preventDefault()\n if (!this.dismissibleValue) return\n this._close(\"dismiss\")\n }\n\n _close(reason) {\n if (this.element.dataset.state === \"closing\") return\n this.element.dataset.state = \"closing\"\n this.element.dispatchEvent(new CustomEvent(reason === \"auto\" ? \"ruby-ui:toast:auto-close\" : \"ruby-ui:toast:dismiss\", { bubbles: true, detail: { id: this.element.id } }))\n setTimeout(() => this.element.remove(), TIME_BEFORE_UNMOUNT)\n }\n\n _start() {\n if (!Number.isFinite(this.durationValue) || this.durationValue <= 0) return\n this._startedAt = performance.now()\n this._remaining = this.durationValue\n this._timer = setTimeout(() => this._close(\"auto\"), this._remaining)\n }\n\n _restart() {\n this._clearTimer()\n this._start()\n }\n\n _pause() {\n if (this._paused || !this._timer) return\n this._paused = true\n clearTimeout(this._timer)\n this._timer = null\n this._remaining -= performance.now() - this._startedAt\n }\n\n _resume() {\n if (!this._paused) return\n this._paused = false\n if (this._remaining <= 0) return this._close(\"auto\")\n this._startedAt = performance.now()\n this._timer = setTimeout(() => this._close(\"auto\"), this._remaining)\n }\n\n _clearTimer() {\n if (this._timer) clearTimeout(this._timer)\n this._timer = null\n }\n\n _onKeyDown(e) {\n if (e.key === \"Escape\" && this.dismissibleValue) this.dismiss(e)\n }\n\n _onPointerDown(e) {\n if (!this.dismissibleValue) return\n if (e.target.closest(\"button\")) return\n try { this.element.setPointerCapture(e.pointerId) } catch {}\n this._swipe = { active: true, x: e.clientX, y: e.clientY, startedAt: performance.now(), pointerId: e.pointerId }\n this.element.dataset.swipe = \"start\"\n this.element.addEventListener(\"pointermove\", this._onPointerMove)\n this.element.addEventListener(\"pointerup\", this._onPointerUp)\n this.element.addEventListener(\"pointercancel\", this._onPointerUp)\n }\n\n _onPointerMove(e) {\n const dx = e.clientX - this._swipe.x\n const dy = e.clientY - this._swipe.y\n this.element.dataset.swipe = \"move\"\n this.element.style.transform = `translate(${dx}px, ${dy}px)`\n }\n\n _onPointerUp(e) {\n const dx = e.clientX - this._swipe.x\n const dy = e.clientY - this._swipe.y\n const dist = Math.hypot(dx, dy)\n const dt = performance.now() - this._swipe.startedAt\n const velocity = dist / Math.max(dt, 1)\n this.element.removeEventListener(\"pointermove\", this._onPointerMove)\n this.element.removeEventListener(\"pointerup\", this._onPointerUp)\n this.element.removeEventListener(\"pointercancel\", this._onPointerUp)\n this._swipe.active = false\n if (dist > SWIPE_THRESHOLD || velocity > 0.5) {\n this.element.style.setProperty(\"--swipe-end-x\", `${Math.sign(dx) * 500}px`)\n this.element.style.setProperty(\"--swipe-end-y\", `${Math.sign(dy) * 500}px`)\n this.element.dataset.swipe = \"end\"\n this.element.style.transform = \"\"\n this._close(\"dismiss\")\n } else {\n this.element.dataset.swipe = \"cancel\"\n this.element.style.transform = \"\"\n this._resume()\n }\n }\n}\n" + }, + { + "path": "toast_description.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ToastDescription < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {slot: \"description\"},\n class: \"font-normal leading-[1.4] text-muted-foreground\"\n }\n end\n end\nend\n" + }, + { + "path": "toast_icon.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ToastIcon < Base\n def initialize(variant: nil, **attrs)\n @variant = variant&.to_sym\n super(**attrs)\n end\n\n def view_template\n return unless renderable?\n span(**attrs) do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n width: \"16\",\n height: \"16\",\n viewbox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n class: \"#{svg_classes} -ml-px\"\n ) { |s| paths(s) }\n end\n end\n\n private\n\n def renderable?\n %i[success error warning info loading].include?(@variant)\n end\n\n def svg_classes\n base = \"size-4\"\n (@variant == :loading) ? \"#{base} animate-spin\" : base\n end\n\n def paths(s)\n case @variant\n when :success\n s.circle(cx: \"12\", cy: \"12\", r: \"10\")\n s.path(d: \"m9 12 2 2 4-4\")\n when :error\n s.path(d: \"M2.586 16.726A2 2 0 0 1 2 15.312V8.688a2 2 0 0 1 .586-1.414l4.688-4.688A2 2 0 0 1 8.688 2h6.624a2 2 0 0 1 1.414.586l4.688 4.688A2 2 0 0 1 22 8.688v6.624a2 2 0 0 1-.586 1.414l-4.688 4.688a2 2 0 0 1-1.414.586H8.688a2 2 0 0 1-1.414-.586z\")\n s.path(d: \"m15 9-6 6\")\n s.path(d: \"m9 9 6 6\")\n when :warning\n s.path(d: \"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3\")\n s.path(d: \"M12 9v4\")\n s.path(d: \"M12 17h.01\")\n when :info\n s.circle(cx: \"12\", cy: \"12\", r: \"10\")\n s.path(d: \"M12 16v-4\")\n s.path(d: \"M12 8h.01\")\n when :loading\n s.path(d: \"M21 12a9 9 0 1 1-6.219-8.56\")\n end\n end\n\n def default_attrs\n {data: {slot: \"icon\"}, class: \"shrink-0 inline-flex items-center justify-start relative size-4 -ml-[3px] mr-1 text-foreground\"}\n end\n end\nend\n" + }, + { + "path": "toast_item.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ToastItem < Base\n ALERT_VARIANTS = %i[error].freeze\n\n def initialize(\n variant: :default,\n id: nil,\n duration: nil,\n dismissible: true,\n invert: false,\n on_dismiss: nil,\n on_auto_close: nil,\n **attrs\n )\n @variant = variant.to_sym\n @id = id\n @duration = duration\n @dismissible = dismissible\n @invert = invert\n @on_dismiss = on_dismiss\n @on_auto_close = on_auto_close\n super(**attrs)\n end\n\n def view_template(&)\n li(**attrs, &)\n end\n\n private\n\n def default_attrs\n a = {\n role: ALERT_VARIANTS.include?(@variant) ? \"alert\" : \"status\",\n aria_atomic: \"true\",\n tabindex: \"0\",\n data: {\n variant: @variant.to_s,\n state: \"pending\",\n swipe: \"none\",\n controller: \"ruby-ui--toast\",\n ruby_ui__toaster_target: \"toast\",\n ruby_ui__toast_dismissible_value: @dismissible.to_s,\n ruby_ui__toast_invert_value: @invert.to_s\n },\n class: item_classes\n }\n a[:id] = @id if @id\n a[:data][:ruby_ui__toast_duration_value] = @duration.to_s if @duration\n a[:data][:ruby_ui__toast_on_dismiss_value] = @on_dismiss if @on_dismiss\n a[:data][:ruby_ui__toast_on_auto_close_value] = @on_auto_close if @on_auto_close\n a\n end\n\n def item_classes\n <<~CLASSES.tr(\"\\n\", \" \").squeeze(\" \").strip\n group/toast pointer-events-auto absolute left-0 right-0\n flex w-[356px] max-w-full items-center gap-1.5\n rounded-lg border bg-popover text-popover-foreground\n border-border p-4 text-[13px] shadow-[0_4px_12px_rgba(0,0,0,0.1)]\n group-data-[close-button=true]/toaster:pr-10\n transition-[transform,opacity] duration-300 ease-out\n will-change-transform\n opacity-[var(--opacity,1)]\n data-[state=pending]:opacity-0\n data-[state=closing]:opacity-0\n data-[swipe=move]:transition-none\n CLASSES\n end\n end\nend\n" + }, + { + "path": "toast_region.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ToastRegion < Base\n SKELETON_VARIANTS = %i[default success error warning info loading].freeze\n\n def initialize(\n position: :bottom_right,\n expand: false,\n max: 3,\n duration: 4000,\n gap: 14,\n offset: 24,\n theme: :system,\n rich_colors: false,\n close_button: false,\n hotkey: %w[alt t],\n dir: :ltr,\n flash: nil,\n **attrs\n )\n @position = position.to_sym\n @expand = expand\n @max = max\n @duration = duration\n @gap = gap\n @offset = offset\n @theme = theme.to_sym\n @rich_colors = rich_colors\n @close_button = close_button\n @hotkey = hotkey\n @dir = dir\n @flash = flash\n super(**attrs)\n end\n\n def view_template(&block)\n div(**attrs) do\n ol(id: \"ruby-ui-toaster\", class: \"pointer-events-auto relative m-0 p-0 list-none w-[356px] max-w-full\") do\n render_flash if @flash\n yield(self) if block\n end\n SKELETON_VARIANTS.each { |v| skeleton(v) }\n slot_template(\"actionTpl\") { render RubyUI::ToastAction.new(label: \"\") }\n slot_template(\"cancelTpl\") { render RubyUI::ToastCancel.new(label: \"\") }\n slot_template(\"closeTpl\") { render RubyUI::ToastClose.new }\n end\n end\n\n private\n\n def render_flash\n @flash.each do |key, message|\n next if message.nil? || message.to_s.empty?\n variant = RubyUI::Toast.flash_variant(key)\n render RubyUI::ToastItem.new(variant: variant, id: \"flash-#{key}\") do\n render RubyUI::ToastIcon.new(variant: variant)\n render RubyUI::ToastTitle.new { message.to_s }\n end\n end\n end\n\n def skeleton(variant)\n template(\n data: {\n ruby_ui__toaster_target: \"skeleton\",\n variant: variant.to_s\n }\n ) do\n render RubyUI::ToastItem.new(variant: variant) do\n render RubyUI::ToastIcon.new(variant: variant)\n div(class: \"flex flex-col gap-0.5 flex-1 min-w-0\") do\n render RubyUI::ToastTitle.new\n render RubyUI::ToastDescription.new\n end\n render RubyUI::ToastClose.new if @close_button\n end\n end\n end\n\n def slot_template(target_name, &)\n template(data: {ruby_ui__toaster_target: target_name}, &)\n end\n\n def default_attrs\n {\n id: \"ruby-ui-toaster-region\",\n role: \"region\",\n aria_label: \"Notifications\",\n aria_live: \"polite\",\n data: {\n controller: \"ruby-ui--toaster\",\n turbo_permanent: \"\",\n close_button: @close_button.to_s,\n position: @position.to_s.tr(\"_\", \"-\"),\n ruby_ui__toaster_position_value: @position.to_s.tr(\"_\", \"-\"),\n ruby_ui__toaster_expand_value: @expand.to_s,\n ruby_ui__toaster_max_value: @max.to_s,\n ruby_ui__toaster_duration_value: @duration.to_s,\n ruby_ui__toaster_gap_value: @gap.to_s,\n ruby_ui__toaster_offset_value: @offset.to_s,\n ruby_ui__toaster_theme_value: @theme.to_s,\n ruby_ui__toaster_rich_colors_value: @rich_colors.to_s,\n ruby_ui__toaster_close_button_value: @close_button.to_s,\n ruby_ui__toaster_hotkey_value: Array(@hotkey).join(\"+\"),\n ruby_ui__toaster_dir_value: @dir.to_s\n },\n class: region_classes\n }\n end\n\n def region_classes\n <<~CLASSES.tr(\"\\n\", \" \").squeeze(\" \").strip\n group/toaster pointer-events-none fixed z-[100] p-4 sm:p-6\n data-[position=top-left]:top-0 data-[position=top-left]:left-0\n data-[position=top-center]:top-0 data-[position=top-center]:left-1/2 data-[position=top-center]:-translate-x-1/2\n data-[position=top-right]:top-0 data-[position=top-right]:right-0\n data-[position=bottom-left]:bottom-0 data-[position=bottom-left]:left-0\n data-[position=bottom-center]:bottom-0 data-[position=bottom-center]:left-1/2 data-[position=bottom-center]:-translate-x-1/2\n data-[position=bottom-right]:bottom-0 data-[position=bottom-right]:right-0\n CLASSES\n end\n end\nend\n" + }, + { + "path": "toast_title.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ToastTitle < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {slot: \"title\"},\n class: \"font-medium leading-normal\"\n }\n end\n end\nend\n" + }, + { + "path": "toaster_controller.js", + "content": "import { Controller } from \"@hotwired/stimulus\"\n\nconst VARIANTS = [\"default\", \"success\", \"error\", \"warning\", \"info\", \"loading\"]\n\nlet streamActionRegistered = false\n\nfunction registerStreamAction() {\n if (streamActionRegistered) return\n if (typeof window === \"undefined\") return\n const Turbo = window.Turbo\n if (!Turbo?.StreamActions) return\n Turbo.StreamActions.toast = function () {\n const detail = {}\n for (const attr of this.attributes) {\n if (attr.name === \"action\" || attr.name === \"target\" || attr.name === \"targets\") continue\n detail[attr.name] = attr.value\n }\n if (detail.duration != null && detail.duration !== \"\") detail.duration = Number(detail.duration)\n if (detail.dismissible != null) detail.dismissible = detail.dismissible !== \"false\"\n window.dispatchEvent(new CustomEvent(\"ruby-ui:toast\", { detail }))\n }\n streamActionRegistered = true\n}\n\n// Connects to data-controller=\"ruby-ui--toaster\"\nexport default class extends Controller {\n static targets = [\"skeleton\", \"toast\", \"actionTpl\", \"cancelTpl\", \"closeTpl\"]\n static values = {\n position: { type: String, default: \"bottom-right\" },\n expand: { type: Boolean, default: false },\n max: { type: Number, default: 3 },\n duration: { type: Number, default: 4000 },\n gap: { type: Number, default: 14 },\n offset: { type: Number, default: 24 },\n theme: { type: String, default: \"system\" },\n richColors: { type: Boolean, default: false },\n closeButton: { type: Boolean, default: false },\n hotkey: { type: String, default: \"alt+t\" },\n dir: { type: String, default: \"ltr\" },\n }\n\n connect() {\n this._heights = new Map()\n this._resizeObservers = new WeakMap()\n this._expanded = this.expandValue\n this._listEl = this.element.querySelector(\"ol\") || (this.element.tagName === \"OL\" ? this.element : null)\n this._registerGlobalApi()\n registerStreamAction()\n if (!this._listEl) return\n\n this._onPointerEnter = () => this._setExpanded(true)\n this._onPointerLeave = () => { if (!this.expandValue) this._setExpanded(false) }\n this._onWindowToast = (e) => this._spawn(e.detail || {})\n this._onWindowDismissAll = () => this._dismissById(null)\n this._onKey = this._onKey.bind(this)\n\n window.addEventListener(\"ruby-ui:toast\", this._onWindowToast)\n window.addEventListener(\"ruby-ui:toast:dismiss-all\", this._onWindowDismissAll)\n this._listEl.addEventListener(\"pointerenter\", this._onPointerEnter)\n this._listEl.addEventListener(\"pointerleave\", this._onPointerLeave)\n document.addEventListener(\"keydown\", this._onKey)\n }\n\n disconnect() {\n window.removeEventListener(\"ruby-ui:toast\", this._onWindowToast)\n window.removeEventListener(\"ruby-ui:toast:dismiss-all\", this._onWindowDismissAll)\n this._listEl?.removeEventListener(\"pointerenter\", this._onPointerEnter)\n this._listEl?.removeEventListener(\"pointerleave\", this._onPointerLeave)\n document.removeEventListener(\"keydown\", this._onKey)\n }\n\n toastTargetConnected(el) {\n if (typeof ResizeObserver !== \"undefined\") {\n const ro = new ResizeObserver(() => {\n this._heights.set(el, el.offsetHeight)\n this._reflow()\n })\n ro.observe(el)\n this._resizeObservers.set(el, ro)\n }\n this._heights.set(el, el.offsetHeight || 64)\n this._reflow()\n }\n\n toastTargetDisconnected(el) {\n this._resizeObservers.get(el)?.disconnect()\n this._resizeObservers.delete(el)\n this._heights.delete(el)\n this._reflow()\n }\n\n _spawn(detail) {\n const variant = VARIANTS.includes(detail.variant) ? detail.variant : \"default\"\n const tpl = this._skeletonFor(variant)\n if (!tpl) return null\n if (detail.position) {\n this.element.setAttribute(\"data-position\", detail.position)\n this.positionValue = detail.position\n }\n const node = tpl.content.firstElementChild.cloneNode(true)\n\n node.id = detail.id || `toast-${this._uuid()}`\n if (detail.duration != null) {\n const dur = detail.duration === Infinity ? 0 : detail.duration\n node.setAttribute(\"data-ruby-ui--toast-duration-value\", String(dur))\n }\n if (detail.dismissible === false) {\n node.setAttribute(\"data-ruby-ui--toast-dismissible-value\", \"false\")\n }\n if (detail.className) node.className += ` ${detail.className}`\n\n const titleEl = node.querySelector('[data-slot=\"title\"]')\n if (titleEl) titleEl.textContent = detail.title || detail.message || \"\"\n const descEl = node.querySelector('[data-slot=\"description\"]')\n if (descEl) {\n if (detail.description) descEl.textContent = detail.description\n else descEl.remove()\n }\n\n if (detail.action && detail.action.label && this.hasActionTplTarget) {\n const btn = this._cloneSlot(this.actionTplTarget)\n btn.textContent = detail.action.label\n btn.addEventListener(\"click\", (ev) => {\n try { detail.action.onClick?.(ev) } finally {\n node.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n }\n })\n node.appendChild(btn)\n }\n\n if (detail.cancel && detail.cancel.label && this.hasCancelTplTarget) {\n const btn = this._cloneSlot(this.cancelTplTarget)\n btn.textContent = detail.cancel.label\n node.appendChild(btn)\n }\n\n if (detail.closeButton && this.hasCloseTplTarget) {\n const x = this._cloneSlot(this.closeTplTarget)\n node.classList.add(\"pr-10\")\n node.appendChild(x)\n }\n\n this._listEl.appendChild(node)\n return node.id\n }\n\n _dismissById(id) {\n if (!id) {\n this.toastTargets.forEach((el) =>\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n )\n return\n }\n const el = this._listEl.querySelector(`#${CSS.escape(id)}`)\n if (el) el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n }\n\n _skeletonFor(variant) {\n return this.skeletonTargets.find((t) => t.dataset.variant === variant)\n }\n\n _cloneSlot(tpl) {\n return tpl.content.firstElementChild.cloneNode(true)\n }\n\n _setExpanded(value) {\n if (this._expanded === value) return\n this._expanded = value\n document.dispatchEvent(new CustomEvent(value ? \"ruby-ui:toast:pause\" : \"ruby-ui:toast:resume\"))\n this._reflow()\n }\n\n _reflow() {\n if (!this._listEl) return\n const isBottom = this.positionValue.startsWith(\"bottom\")\n const items = this.toastTargets\n const order = isBottom ? items.slice().reverse() : items.slice()\n const heights = order.map(el => this._heights.get(el) || el.offsetHeight || 64)\n const gap = this.gapValue\n const peekOffset = 16\n const peekScaleStep = 0.05\n const peekOpacityStep = 0.2\n\n const expandedHeight = heights.reduce((a, b) => a + b, 0) + gap * Math.max(0, heights.length - 1)\n const collapsedHeight = (heights[0] || 0) + Math.min(2, Math.max(0, heights.length - 1)) * peekOffset\n this._listEl.style.minHeight = `${this._expanded ? expandedHeight : collapsedHeight}px`\n\n let acc = 0\n order.forEach((el, i) => {\n const visible = i < this.maxValue\n let yOffset, scale, opacity\n\n if (this._expanded) {\n yOffset = acc + i * gap\n scale = 1\n opacity = visible ? 1 : 0\n } else {\n yOffset = i * peekOffset\n scale = Math.max(0.85, 1 - i * peekScaleStep)\n opacity = visible ? Math.max(0, 1 - i * peekOpacityStep) : 0\n }\n\n const sign = isBottom ? -1 : 1\n const ty = sign * yOffset\n\n el.style.setProperty(\"--opacity\", String(opacity))\n el.style.setProperty(\"--scale\", String(scale))\n el.style.setProperty(\"--y-offset\", `${ty}px`)\n el.style.transformOrigin = isBottom ? \"center bottom\" : \"center top\"\n el.style.top = isBottom ? \"auto\" : \"0\"\n el.style.bottom = isBottom ? \"0\" : \"auto\"\n el.style.transform = `translate3d(0, ${ty}px, 0) scale(${scale})`\n el.style.zIndex = String(1000 - i)\n el.style.pointerEvents = visible ? \"auto\" : \"none\"\n el.tabIndex = visible ? 0 : -1\n\n acc += heights[i] || 0\n })\n\n this._enforceMax(items)\n }\n\n _enforceMax(items) {\n if (items.length <= this.maxValue) return\n const isBottom = this.positionValue.startsWith(\"bottom\")\n const dropping = items.length - this.maxValue\n const candidates = isBottom ? items.slice(0, dropping) : items.slice(-dropping)\n candidates.forEach(el => {\n if (el.dataset.state !== \"closing\") {\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n }\n })\n }\n\n _onKey(e) {\n const parts = (this.hotkeyValue || \"alt+t\").split(\"+\")\n const key = parts.pop()\n const wantAlt = parts.includes(\"alt\")\n const wantCtrl = parts.includes(\"ctrl\")\n const wantMeta = parts.includes(\"meta\")\n if (e.key.toLowerCase() !== key.toLowerCase()) return\n if (wantAlt !== e.altKey) return\n if (wantCtrl !== e.ctrlKey) return\n if (wantMeta !== e.metaKey) return\n e.preventDefault()\n const first = this._listEl.firstElementChild\n first?.focus()\n }\n\n _registerGlobalApi() {\n const fire = (variant, message, opts = {}) =>\n window.dispatchEvent(new CustomEvent(\"ruby-ui:toast\", {\n detail: { ...opts, variant, message: opts.title || message }\n }))\n\n const api = (message, opts) => fire(\"default\", message, opts)\n api.success = (m, o) => fire(\"success\", m, o)\n api.error = (m, o) => fire(\"error\", m, o)\n api.warning = (m, o) => fire(\"warning\", m, o)\n api.info = (m, o) => fire(\"info\", m, o)\n api.loading = (m, o = {}) => fire(\"loading\", m, { ...o, duration: o.duration ?? 0 })\n api.dismiss = (id) => {\n if (id) this._dismissById(id)\n else window.dispatchEvent(new CustomEvent(\"ruby-ui:toast:dismiss-all\"))\n }\n api.promise = (p, msgs = {}) => {\n const id = `toast-${this._uuid()}`\n fire(\"loading\", typeof msgs.loading === \"function\" ? msgs.loading() : (msgs.loading || \"Loading...\"), { id, duration: 0 })\n Promise.resolve(p).then(\n (val) => this._mutate(id, \"success\", typeof msgs.success === \"function\" ? msgs.success(val) : msgs.success),\n (err) => this._mutate(id, \"error\", typeof msgs.error === \"function\" ? msgs.error(err) : msgs.error)\n )\n return id\n }\n\n window.RubyUI = window.RubyUI || {}\n window.RubyUI.toast = api\n }\n\n _mutate(id, variant, text) {\n const el = this._listEl.querySelector(`#${CSS.escape(id)}`)\n if (!el) return\n el.dataset.variant = variant\n el.setAttribute(\"role\", variant === \"error\" ? \"alert\" : \"status\")\n this._swapIcon(el, variant)\n const t = el.querySelector('[data-slot=\"title\"]')\n if (t && text) t.textContent = text\n const dur = String(this.durationValue)\n el.setAttribute(\"data-ruby-ui--toast-duration-value\", dur)\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:restart\", { bubbles: true }))\n }\n\n _swapIcon(el, variant) {\n const iconHost = el.querySelector('[data-slot=\"icon\"]')\n if (!iconHost) return\n const tpl = this._skeletonFor(variant)\n if (!tpl) return\n const sourceIcon = tpl.content.firstElementChild?.querySelector('[data-slot=\"icon\"]')\n iconHost.innerHTML = sourceIcon ? sourceIcon.innerHTML : \"\"\n }\n\n _uuid() {\n if (typeof crypto !== \"undefined\" && crypto.randomUUID) return crypto.randomUUID()\n return Math.random().toString(36).slice(2) + Date.now().toString(36)\n }\n}\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Toast", + "docs_markdown": "Hotwire-native sonner port. Mount once; trigger via Turbo Stream or window.RubyUI.toast.", + "examples": [] + }, + "toggle": { + "name": "Toggle", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "toggle.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Toggle < Base\n BASE_CLASSES = [\n \"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap\",\n \"transition-[color,box-shadow] outline-none\",\n \"hover:bg-muted hover:text-muted-foreground\",\n \"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50\",\n \"disabled:pointer-events-none disabled:opacity-50\",\n \"aria-invalid:border-destructive aria-invalid:ring-destructive/20\",\n \"data-[state=on]:bg-accent data-[state=on]:text-accent-foreground\",\n \"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\"\n ].freeze\n\n VARIANT_CLASSES = {\n default: \"bg-transparent\",\n outline: \"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground\"\n }.freeze\n\n SIZE_CLASSES = {\n sm: \"h-8 min-w-8 px-1.5\",\n default: \"h-9 min-w-9 px-2\",\n lg: \"h-10 min-w-10 px-2.5\"\n }.freeze\n\n def self.classes_for(variant:, size:)\n [BASE_CLASSES, VARIANT_CLASSES.fetch(variant, VARIANT_CLASSES[:default]), SIZE_CLASSES.fetch(size, SIZE_CLASSES[:default])]\n end\n\n def initialize(\n pressed: false,\n name: nil,\n value: \"1\",\n unpressed_value: nil,\n variant: :default,\n size: :default,\n disabled: false,\n wrapper: {},\n **attrs\n )\n @pressed = pressed\n @name = name\n @value = value\n @unpressed_value = unpressed_value\n @variant = variant.to_sym\n @size = size.to_sym\n @disabled = disabled\n @wrapper = wrapper\n super(**attrs)\n end\n\n def view_template(&block)\n span(**wrapper_attrs) do\n button(**attrs, &block)\n render_hidden_input if @name\n end\n end\n\n private\n\n def wrapper_attrs\n mix(wrapper_default_attrs, @wrapper)\n end\n\n def wrapper_default_attrs\n {\n class: \"contents\",\n data: {\n controller: \"ruby-ui--toggle\",\n action: \"click->ruby-ui--toggle#toggle\",\n \"ruby-ui--toggle-pressed-value\": @pressed.to_s,\n \"ruby-ui--toggle-value-value\": @value.to_s,\n \"ruby-ui--toggle-unpressed-value-value\": @unpressed_value.to_s\n }\n }\n end\n\n def render_hidden_input\n input(\n type: \"hidden\",\n name: @name,\n value: @pressed ? @value : @unpressed_value.to_s,\n data: {\"ruby-ui--toggle-target\": \"input\"}\n )\n end\n\n def default_attrs\n base = {type: \"button\"}\n base[:disabled] = true if @disabled\n base.merge(\n aria: {pressed: @pressed.to_s},\n data: {\n state: @pressed ? \"on\" : \"off\",\n \"ruby-ui--toggle-target\": \"button\"\n },\n class: self.class.classes_for(variant: @variant, size: @size)\n )\n end\n end\nend\n" + }, + { + "path": "toggle_controller.js", + "content": "import { Controller } from \"@hotwired/stimulus\"\n\n// Connects to data-controller=\"ruby-ui--toggle\"\n// Sits on a wrapper element; the visible