From af7d6efd0790984d5bd5809298fdb8aaa71c1780 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 27 Jul 2026 17:26:28 -0300 Subject: [PATCH 1/2] [Improvement] Docs: symlink Stimulus controllers to gem source instead of copying docs/app/javascript/controllers/ruby_ui/*_controller.js was a hand-maintained copy of gem/lib/ruby_ui//*_controller.js. The two silently drifted: the gem's accordion fix (0897b2a) never landed in the docs copy, breaking the live accordion preview. Replace all 37 copies with relative symlinks into the gem source, matching how docs already autoloads Phlex components straight from gem/ (config/initializers/ruby_ui.rb) with no copy step. - accordion_controller.js, avatar_controller.js, combobox_controller.js, and sheet_controller.js had genuinely stale docs content pre-migration (beyond just accordion) - the symlink now serves the gem's current, correct version. - esbuild's default symlink handling resolves module imports relative to the symlink's real (gem/) path, which breaks node_modules resolution since gem/ and docs/ are siblings with no shared ancestor node_modules. Build with --preserve-symlinks so imports resolve against the symlink location instead. - Add `rake ruby_ui:sync_controller_symlinks` (docs/lib/tasks/ruby_ui.rake): scans gem/lib/ruby_ui for *_controller.js and creates/repairs the matching symlink. Idempotent. Replaces the old "copy the file by hand" step for new components; stimulus:manifest:update is still required separately to register a brand-new controller in the manifest. - Update CLAUDE.md, docs/CLAUDE.md, gem/AGENTS.md, and the ruby-ui-stimulus skill to describe the symlink relationship and new workflow. Rebased onto main after PR #490 and PR #495 merged. Both edited a docs controller copy that this branch replaces with a symlink (accordion, popover), so both conflicted as modify/delete. Resolved in favour of the symlink: on main the docs copy and the gem source are byte-identical for each of the two, so pointing at the gem loses neither fix, and #490's docs-side change becomes moot now that the file is a symlink. --- .claude/skills/ruby-ui-stimulus/SKILL.md | 4 + CLAUDE.md | 1 + docs/CLAUDE.md | 13 + .../ruby_ui/accordion_controller.js | 113 +----- .../ruby_ui/alert_dialog_controller.js | 32 +- .../controllers/ruby_ui/avatar_controller.js | 30 +- .../ruby_ui/calendar_controller.js | 309 +--------------- .../ruby_ui/calendar_input_controller.js | 9 +- .../ruby_ui/carousel_controller.js | 61 +--- .../controllers/ruby_ui/chart_controller.js | 104 +----- .../ruby_ui/checkbox_group_controller.js | 22 +- .../ruby_ui/clipboard_controller.js | 55 +-- .../ruby_ui/collapsible_controller.js | 48 +-- .../ruby_ui/combobox_controller.js | 192 +--------- .../controllers/ruby_ui/command_controller.js | 127 +------ .../ruby_ui/command_dialog_controller.js | 35 +- .../ruby_ui/context_menu_controller.js | 157 +------- ...data_table_column_visibility_controller.js | 25 +- .../ruby_ui/data_table_controller.js | 58 +-- .../ruby_ui/data_table_search_controller.js | 63 +--- .../controllers/ruby_ui/dialog_controller.js | 45 +-- .../ruby_ui/dropdown_menu_controller.js | 154 +------- .../ruby_ui/form_field_controller.js | 62 +--- .../ruby_ui/hover_card_controller.js | 182 +--------- .../ruby_ui/input_otp_controller.js | 129 +------ .../ruby_ui/masked_input_controller.js | 10 +- .../ruby_ui/message_scroller_controller.js | 336 +----------------- .../controllers/ruby_ui/popover_controller.js | 171 +-------- .../controllers/ruby_ui/select_controller.js | 172 +-------- .../ruby_ui/select_item_controller.js | 12 +- .../ruby_ui/sheet_content_controller.js | 8 +- .../controllers/ruby_ui/sheet_controller.js | 10 +- .../controllers/ruby_ui/sidebar_controller.js | 68 +--- .../controllers/ruby_ui/tabs_controller.js | 46 +-- .../ruby_ui/theme_toggle_controller.js | 39 +- .../controllers/ruby_ui/toast_controller.js | 152 +------- .../controllers/ruby_ui/toaster_controller.js | 307 +--------------- .../controllers/ruby_ui/toggle_controller.js | 34 +- .../ruby_ui/toggle_group_controller.js | 127 +------ .../controllers/ruby_ui/tooltip_controller.js | 75 +--- docs/lib/tasks/ruby_ui.rake | 41 +++ docs/package.json | 2 +- gem/AGENTS.md | 2 + 43 files changed, 99 insertions(+), 3543 deletions(-) mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/accordion_controller.js mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/alert_dialog_controller.js mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/avatar_controller.js mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/calendar_controller.js mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/calendar_input_controller.js mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/carousel_controller.js mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/chart_controller.js mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/checkbox_group_controller.js mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/clipboard_controller.js mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/collapsible_controller.js mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/combobox_controller.js mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/command_controller.js mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/command_dialog_controller.js mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/context_menu_controller.js mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/data_table_column_visibility_controller.js mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/data_table_controller.js mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/data_table_search_controller.js mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/dialog_controller.js mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/dropdown_menu_controller.js mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/form_field_controller.js mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/hover_card_controller.js mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/input_otp_controller.js mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/masked_input_controller.js mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/message_scroller_controller.js mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/popover_controller.js mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/select_controller.js mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/select_item_controller.js mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/sheet_content_controller.js mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/sheet_controller.js mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/sidebar_controller.js mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/tabs_controller.js mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/theme_toggle_controller.js mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/toast_controller.js mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/toaster_controller.js mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/toggle_controller.js mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/toggle_group_controller.js mode change 100644 => 120000 docs/app/javascript/controllers/ruby_ui/tooltip_controller.js create mode 100644 docs/lib/tasks/ruby_ui.rake diff --git a/.claude/skills/ruby-ui-stimulus/SKILL.md b/.claude/skills/ruby-ui-stimulus/SKILL.md index 8451f7db2..bbf99e14b 100644 --- a/.claude/skills/ruby-ui-stimulus/SKILL.md +++ b/.claude/skills/ruby-ui-stimulus/SKILL.md @@ -41,6 +41,10 @@ Building or changing a Stimulus-backed component: - Importmap apps eager-load `controllers/` — no manifest edit needed. - esbuild/webpack apps regenerate the manifest with `rake stimulus:manifest:update` (`docs/app/javascript/controllers/index.js`). + - `docs/app/javascript/controllers/ruby_ui/_controller.js` is a + symlink to the gem file above, not a copy. For a brand-new controller, run + `bin/rails ruby_ui:sync_controller_symlinks` in `docs/` first to create the + symlink, then `stimulus:manifest:update` to register it. - New JS packages go in `gem/package.json` **and** per-component in `gem/lib/generators/ruby_ui/dependencies.yml`. 5. **Update docs & tests** in the same PR: `_docs.rb` and diff --git a/CLAUDE.md b/CLAUDE.md index e6a36262d..44df0ac16 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,7 @@ Subproject-specific instructions live in `gem/AGENTS.md` and `docs/CLAUDE.md` ( - Component docs view → `docs/app/views/docs/.rb`. Update in same PR as the component. - Generator/installer logic → `gem/lib/generators/ruby_ui/`. Dependency map → `gem/lib/generators/ruby_ui/dependencies.yml`. - Site chrome, routes, marketing pages → `docs/app/`. +- A component's Stimulus controller lives only in `gem/lib/ruby_ui//_controller.js`. `docs/app/javascript/controllers/ruby_ui/_controller.js` is a symlink to it, not a copy — editing the gem file is enough for existing components. A brand-new component's controller needs `docs`' `rake ruby_ui:sync_controller_symlinks` to create the symlink, plus `bin/rails stimulus:manifest:update` to register it. ## Common commands diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md index 9df952cbd..f55dbd8cf 100644 --- a/docs/CLAUDE.md +++ b/docs/CLAUDE.md @@ -19,3 +19,16 @@ bin/rails site_files:generate ``` Before finishing, review the diff for `public/llms.txt`, `public/llms-full.txt`, and `public/sitemap.xml`. These generated files should be committed with the route/controller/view change so deployed apps expose the updated root files without a manual step. + +## Stimulus Controllers + +`app/javascript/controllers/ruby_ui/*_controller.js` are symlinks into `gem/lib/ruby_ui//`, not copies — there is no independent docs version to keep in sync. + +Adding a brand-new component's controller: + +```bash +bin/rails ruby_ui:sync_controller_symlinks # creates the missing symlink(s) +bin/rails stimulus:manifest:update # registers it in controllers/index.js +``` + +`sync_controller_symlinks` (in `lib/tasks/ruby_ui.rake`) is idempotent — safe to re-run any time. It only creates/repairs symlinks; it does not touch the manifest, so `stimulus:manifest:update` is still a separate required step for a controller the manifest hasn't seen yet. diff --git a/docs/app/javascript/controllers/ruby_ui/accordion_controller.js b/docs/app/javascript/controllers/ruby_ui/accordion_controller.js deleted file mode 100644 index 3ed2952c2..000000000 --- a/docs/app/javascript/controllers/ruby_ui/accordion_controller.js +++ /dev/null @@ -1,112 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; -import { animate } from "motion"; - -// Connects to data-controller="ruby-ui--accordion" -export default class extends Controller { - static targets = ["icon", "content"]; - static values = { - open: { - type: Boolean, - default: false, - }, - animationDuration: { - type: Number, - default: 0.15, // Default animation duration (in seconds) - }, - animationEasing: { - type: String, - default: "ease-in-out", // Default animation easing - }, - rotateIcon: { - type: Number, - default: 180, // Default icon rotation (in degrees) - }, - }; - - connect() { - // Set the initial state of the accordion - let originalAnimationDuration = this.animationDurationValue; - this.animationDurationValue = 0; - this.openValue ? this.open() : this.close(); - this.animationDurationValue = originalAnimationDuration; - } - - // Toggle the 'open' value - toggle() { - this.openValue = !this.openValue; - } - - // Handle changes in the 'open' value - openValueChanged(isOpen, wasOpen) { - if (isOpen) { - this.open(); - } else { - this.close(); - } - } - - // Open the accordion content - open() { - if (this.hasContentTarget) { - this.revealContent(); - this.hasIconTarget && this.rotateIcon(); - this.openValue = true; - } - } - - // Close the accordion content - close() { - if (this.hasContentTarget) { - this.hideContent(); - this.hasIconTarget && this.rotateIcon(); - this.openValue = false; - } - } - - // Reveal the accordion content with animation - revealContent() { - const content = this.contentTarget; - - // Remove hidden so the element participates in layout before measuring - content.removeAttribute("hidden"); - content.dataset.state = "open"; - - const contentHeight = content.scrollHeight; - animate( - content, - { height: `${contentHeight}px` }, - { - duration: this.animationDurationValue, - easing: this.animationEasingValue, - }, - ); - } - - // Hide the accordion content with animation - hideContent() { - const content = this.contentTarget; - content.dataset.state = "closed"; - - animate( - content, - { height: 0 }, - { - duration: this.animationDurationValue, - easing: this.animationEasingValue, - }, - ).finished.then(() => { - // After animation completes, truly hide the element so it is removed - // from layout and form focus — prevents trapped validation errors - if (content.dataset.state === "closed") { - content.setAttribute("hidden", ""); - } - }); - } - - // Rotate the accordion icon 180deg using animate function - rotateIcon() { - animate(this.iconTarget, { - rotate: `${this.openValue ? this.rotateIconValue : 0}deg`, - }); - } -} diff --git a/docs/app/javascript/controllers/ruby_ui/accordion_controller.js b/docs/app/javascript/controllers/ruby_ui/accordion_controller.js new file mode 120000 index 000000000..cd3e14a77 --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/accordion_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/accordion/accordion_controller.js \ No newline at end of file diff --git a/docs/app/javascript/controllers/ruby_ui/alert_dialog_controller.js b/docs/app/javascript/controllers/ruby_ui/alert_dialog_controller.js deleted file mode 100644 index 98952e955..000000000 --- a/docs/app/javascript/controllers/ruby_ui/alert_dialog_controller.js +++ /dev/null @@ -1,31 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; - -// Connects to data-controller="ruby-ui--alert-dialog" -export default class extends Controller { - static targets = ["content"]; - static values = { - open: { - type: Boolean, - default: false, - }, - }; - - connect() { - if (this.openValue) { - this.open(); - } - } - - open() { - document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML); - // prevent scroll on body - document.body.classList.add("overflow-hidden"); - } - - dismiss(e) { - // allow scroll on body - document.body.classList.remove("overflow-hidden"); - // remove the element - this.element.remove(); - } -} diff --git a/docs/app/javascript/controllers/ruby_ui/alert_dialog_controller.js b/docs/app/javascript/controllers/ruby_ui/alert_dialog_controller.js new file mode 120000 index 000000000..aa59dd123 --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/alert_dialog_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/alert_dialog/alert_dialog_controller.js \ No newline at end of file diff --git a/docs/app/javascript/controllers/ruby_ui/avatar_controller.js b/docs/app/javascript/controllers/ruby_ui/avatar_controller.js deleted file mode 100644 index 3643f26a7..000000000 --- a/docs/app/javascript/controllers/ruby_ui/avatar_controller.js +++ /dev/null @@ -1,29 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; - -export default class extends Controller { - static targets = ["image", "fallback"]; - - connect() { - if (!this.hasImageTarget) return; - - if (this.imageTarget.complete && this.imageTarget.naturalWidth > 0) { - this.showImage(); - } else { - this.showFallback(); - } - } - - showImage() { - this.imageTargets.forEach((image) => image.classList.remove("hidden")); - this.fallbackTargets.forEach((fallback) => - fallback.classList.add("hidden"), - ); - } - - showFallback() { - this.imageTargets.forEach((image) => image.classList.add("hidden")); - this.fallbackTargets.forEach((fallback) => - fallback.classList.remove("hidden"), - ); - } -} diff --git a/docs/app/javascript/controllers/ruby_ui/avatar_controller.js b/docs/app/javascript/controllers/ruby_ui/avatar_controller.js new file mode 120000 index 000000000..ffda9a17c --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/avatar_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/avatar/avatar_controller.js \ No newline at end of file diff --git a/docs/app/javascript/controllers/ruby_ui/calendar_controller.js b/docs/app/javascript/controllers/ruby_ui/calendar_controller.js deleted file mode 100644 index b355e9361..000000000 --- a/docs/app/javascript/controllers/ruby_ui/calendar_controller.js +++ /dev/null @@ -1,308 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; -import Mustache from "mustache"; - -export default class extends Controller { - static targets = [ - "calendar", - "title", - "weekdaysTemplate", - "disabledDateTemplate", - "selectedDateTemplate", - "todayDateTemplate", - "currentMonthDateTemplate", - "otherMonthDateTemplate", - ]; - static values = { - selectedDate: { - type: String, - default: null, - }, - minDate: { - type: String, - default: null, - }, - viewDate: { - type: String, - default: new Date().toISOString().slice(0, 10), - }, - format: { - type: String, - default: "yyyy-MM-dd", // Default format - }, - }; - static outlets = ["ruby-ui--calendar-input"]; - - initialize() { - this.updateCalendar(); // Initial calendar render - } - - nextMonth(e) { - e.preventDefault(); - this.viewDateValue = this.adjustMonth(1); - } - - prevMonth(e) { - e.preventDefault(); - this.viewDateValue = this.adjustMonth(-1); - } - - selectDay(e) { - e.preventDefault(); - if (this.isDateDisabled(e.currentTarget.dataset.day)) return; - - // Set the selected date value - this.selectedDateValue = e.currentTarget.dataset.day; - } - - selectedDateValueChanged(value, prevValue) { - const selectedDate = this.selectedDate(); - if (!selectedDate) { - this.updateCalendar(); - return; - } - - // update the viewDateValue to the first day of month of the selected date (This will trigger updateCalendar() function) - const newViewDate = new Date(selectedDate); - newViewDate.setDate(2); // set the day to the 2nd (to avoid issues with months with different number of days and timezones) - this.viewDateValue = newViewDate.toISOString().slice(0, 10); - - // Re-render the calendar - this.updateCalendar(); - - // update the input value - this.rubyUiCalendarInputOutlets.forEach((outlet) => { - const formattedDate = this.formatDate(selectedDate); - outlet.setValue(formattedDate); - }); - } - - viewDateValueChanged(value, prevValue) { - this.updateCalendar(); - } - - adjustMonth(adjustment) { - const date = this.viewDate(); - date.setDate(2); // set the day to the 2nd (to avoid issues with months with different number of days and timezones) - date.setMonth(date.getMonth() + adjustment); - return date.toISOString().slice(0, 10); - } - - updateCalendar() { - // Update the title with month and year - this.titleTarget.textContent = this.monthAndYear(); - this.calendarTarget.innerHTML = this.calendarHTML(); - } - - calendarHTML() { - return this.weekdaysTemplateTarget.innerHTML + this.calendarDays(); - } - - calendarDays() { - return this.getFullWeeksStartAndEndInMonth() - .map((week) => this.renderWeek(week)) - .join(""); - } - - renderWeek(week) { - const days = week - .map((day) => { - return this.renderDay(day); - }) - .join(""); - return `${days}`; - } - - renderDay(day) { - const today = new Date(); - const selectedDate = this.selectedDate(); - let dateHTML = ""; - const data = { day: day, dayDate: day.getDate() }; - - if (this.isDateDisabled(day)) { - // disabledDate - dateHTML = Mustache.render( - this.disabledDateTemplateTarget.innerHTML, - data, - ); - } else if ( - selectedDate && - day.toDateString() === selectedDate.toDateString() - ) { - // selectedDate - // Render the selected date template target innerHTML with Mustache - dateHTML = Mustache.render( - this.selectedDateTemplateTarget.innerHTML, - data, - ); - } else if (day.toDateString() === today.toDateString()) { - // todayDate - dateHTML = Mustache.render(this.todayDateTemplateTarget.innerHTML, data); - } else if (day.getMonth() === this.viewDate().getMonth()) { - // currentMonthDate - dateHTML = Mustache.render( - this.currentMonthDateTemplateTarget.innerHTML, - data, - ); - } else { - // otherMonthDate - dateHTML = Mustache.render( - this.otherMonthDateTemplateTarget.innerHTML, - data, - ); - } - return dateHTML; - } - - monthAndYear() { - const month = this.viewDate().toLocaleString("en-US", { month: "long" }); - const year = this.viewDate().getFullYear(); - return `${month} ${year}`; - } - - selectedDate() { - return this.parseDate(this.selectedDateValue); - } - - viewDate() { - return ( - this.parseDate(this.viewDateValue) || this.selectedDate() || new Date() - ); - } - - getFullWeeksStartAndEndInMonth() { - const month = this.viewDate().getMonth(); - const year = this.viewDate().getFullYear(); - - let weeks = [], - firstDate = new Date(year, month, 1), - lastDate = new Date(year, month + 1, 0), - numDays = lastDate.getDate(); - - let start = 1; - let end; - if (firstDate.getDay() === 1) { - end = 7; - } else if (firstDate.getDay() === 0) { - let preMonthEndDay = new Date(year, month, 0); - start = preMonthEndDay.getDate() - 6 + 1; - end = 1; - } else { - let preMonthEndDay = new Date(year, month, 0); - start = preMonthEndDay.getDate() + 1 - firstDate.getDay() + 1; - end = 7 - firstDate.getDay() + 1; - weeks.push({ - start: start, - end: end, - }); - start = end + 1; - end = end + 7; - } - while (start <= numDays) { - weeks.push({ - start: start, - end: end, - }); - start = end + 1; - end = end + 7; - end = start === 1 && end === 8 ? 1 : end; - if (end > numDays && start <= numDays) { - end = end - numDays; - weeks.push({ - start: start, - end: end, - }); - break; - } - } - // *** the magic starts here - return weeks.map(({ start, end }, index) => { - const sub = +(start > end && index === 0); - return Array.from({ length: 7 }, (_, index) => { - const date = new Date(year, month - sub, start + index); - return date; - }); - }); - } - - formatDate(date) { - const format = this.formatValue; - const day = date.getDate(); - const month = date.getMonth() + 1; - const year = date.getFullYear(); - const hours = date.getHours(); - const minutes = date.getMinutes(); - const seconds = date.getSeconds(); - const dayOfWeek = date.toLocaleString("en-US", { weekday: "long" }); - const monthName = date.toLocaleString("en-US", { month: "long" }); - const daySuffix = this.getDaySuffix(day); - - const map = { - yyyy: year, - MM: ("0" + month).slice(-2), - dd: ("0" + day).slice(-2), - HH: ("0" + hours).slice(-2), - mm: ("0" + minutes).slice(-2), - ss: ("0" + seconds).slice(-2), - EEEE: dayOfWeek, - MMMM: monthName, - do: day + daySuffix, - PPPP: `${dayOfWeek}, ${monthName} ${day}${daySuffix}, ${year}`, - }; - - const formattedDate = format.replace( - /yyyy|MM|dd|HH|mm|ss|EEEE|MMMM|do|PPPP/g, - (matched) => map[matched], - ); - return formattedDate; - } - - getDaySuffix(day) { - if (day > 3 && day < 21) return "th"; - switch (day % 10) { - case 1: - return "st"; - case 2: - return "nd"; - case 3: - return "rd"; - default: - return "th"; - } - } - - minDate() { - return this.parseDate(this.minDateValue); - } - - isDateDisabled(date) { - const minDate = this.minDate(); - const candidate = this.parseDate(date); - - if (!minDate || !candidate) return false; - - return this.startOfDay(candidate) < this.startOfDay(minDate); - } - - parseDate(value) { - if (!value) return null; - if (value instanceof Date) return new Date(value); - - const isoDate = value.toString().match(/^(\d{4})-(\d{2})-(\d{2})/); - if (isoDate) { - return new Date( - Number(isoDate[1]), - Number(isoDate[2]) - 1, - Number(isoDate[3]), - ); - } - - const date = new Date(value); - return Number.isNaN(date.getTime()) ? null : date; - } - - startOfDay(date) { - const normalizedDate = new Date(date); - normalizedDate.setHours(0, 0, 0, 0); - return normalizedDate; - } -} diff --git a/docs/app/javascript/controllers/ruby_ui/calendar_controller.js b/docs/app/javascript/controllers/ruby_ui/calendar_controller.js new file mode 120000 index 000000000..2e88141a3 --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/calendar_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/calendar/calendar_controller.js \ No newline at end of file diff --git a/docs/app/javascript/controllers/ruby_ui/calendar_input_controller.js b/docs/app/javascript/controllers/ruby_ui/calendar_input_controller.js deleted file mode 100644 index 17b48adc3..000000000 --- a/docs/app/javascript/controllers/ruby_ui/calendar_input_controller.js +++ /dev/null @@ -1,8 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -// Connects to data-controller="input" -export default class extends Controller { - setValue(value) { - this.element.value = value - } -} diff --git a/docs/app/javascript/controllers/ruby_ui/calendar_input_controller.js b/docs/app/javascript/controllers/ruby_ui/calendar_input_controller.js new file mode 120000 index 000000000..53fcd6ce4 --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/calendar_input_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/calendar/calendar_input_controller.js \ No newline at end of file diff --git a/docs/app/javascript/controllers/ruby_ui/carousel_controller.js b/docs/app/javascript/controllers/ruby_ui/carousel_controller.js deleted file mode 100644 index cc7402c51..000000000 --- a/docs/app/javascript/controllers/ruby_ui/carousel_controller.js +++ /dev/null @@ -1,60 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; -import EmblaCarousel from 'embla-carousel' - -const DEFAULT_OPTIONS = { - loop: true -} - -export default class extends Controller { - static values = { - options: { - type: Object, - default: {}, - } - } - static targets = ["viewport", "nextButton", "prevButton"] - - connect() { - this.initCarousel(this.#mergedOptions) - } - - disconnect() { - this.destroyCarousel() - } - - initCarousel(options, plugins = []) { - this.carousel = EmblaCarousel(this.viewportTarget, options, plugins) - - this.carousel.on("init", this.#updateControls.bind(this)) - this.carousel.on("reInit", this.#updateControls.bind(this)) - this.carousel.on("select", this.#updateControls.bind(this)) - } - - destroyCarousel() { - this.carousel.destroy() - } - - scrollNext() { - this.carousel.scrollNext() - } - - scrollPrev() { - this.carousel.scrollPrev() - } - - #updateControls() { - this.#toggleButtonsDisabledState(this.nextButtonTargets, !this.carousel.canScrollNext()) - this.#toggleButtonsDisabledState(this.prevButtonTargets, !this.carousel.canScrollPrev()) - } - - #toggleButtonsDisabledState(buttons, isDisabled) { - buttons.forEach((button) => button.disabled = isDisabled) - } - - get #mergedOptions() { - return { - ...DEFAULT_OPTIONS, - ...this.optionsValue - } - } -} diff --git a/docs/app/javascript/controllers/ruby_ui/carousel_controller.js b/docs/app/javascript/controllers/ruby_ui/carousel_controller.js new file mode 120000 index 000000000..e2d33e654 --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/carousel_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/carousel/carousel_controller.js \ No newline at end of file diff --git a/docs/app/javascript/controllers/ruby_ui/chart_controller.js b/docs/app/javascript/controllers/ruby_ui/chart_controller.js deleted file mode 100644 index 18034447f..000000000 --- a/docs/app/javascript/controllers/ruby_ui/chart_controller.js +++ /dev/null @@ -1,103 +0,0 @@ -import { Controller } from "@hotwired/stimulus" -import Chart from 'chart.js/auto' - -// Chart controller -export default class extends Controller { - static values = { - options: { - type: Object, - default: {}, - } - } - - // Function to initialize the chart when the controller is connected - connect() { - this.initDarkModeObserver() - this.initChart() - } - - disconnect() { - this.darkModeObserver?.disconnect() - this.chart?.destroy() - } - - // Function to initialize the chart - initChart() { - this.setColors() - const ctx = this.element.getContext('2d'); - this.chart = new Chart(ctx, this.mergeOptionsWithDefaults()); - } - - setColors() { - this.setDefaultColorsForChart() - } - - getThemeColor(name) { - const color = getComputedStyle(document.documentElement).getPropertyValue(`--${name}`) - const [hue, saturation, lightness] = color.split(' ') - return `hsl(${hue}, ${saturation}, ${lightness})` - } - - defaultThemeColor() { - return { - backgroundColor: this.getThemeColor('background'), - hoverBackgroundColor: this.getThemeColor('accent'), - borderColor: this.getThemeColor('primary'), - borderWidth: 1, - } - } - - // Function to set chart default colors - setDefaultColorsForChart() { - Chart.defaults.color = this.getThemeColor('muted-foreground') // font color - Chart.defaults.borderColor = this.getThemeColor('border') // border color - Chart.defaults.backgroundColor = this.getThemeColor('background') // background color - - // tooltip colors - Chart.defaults.plugins.tooltip.backgroundColor = this.getThemeColor('background') - Chart.defaults.plugins.tooltip.borderColor = this.getThemeColor('border') - Chart.defaults.plugins.tooltip.titleColor = this.getThemeColor('foreground') - Chart.defaults.plugins.tooltip.bodyColor = this.getThemeColor('muted-foreground') - Chart.defaults.plugins.tooltip.borderWidth = 1 - - // legend - // options.plugins.legend.labels - Chart.defaults.plugins.legend.labels.boxWidth = 12 - Chart.defaults.plugins.legend.labels.boxHeight = 12 - Chart.defaults.plugins.legend.labels.borderWidth = 0 - Chart.defaults.plugins.legend.labels.useBorderRadius = true - Chart.defaults.plugins.legend.labels.borderRadius = this.getThemeColor('radius') - } - - // Function to refresh the chart - refreshChart() { - // Destroy the chart if it's a valid Chart.js instance - this.chart?.destroy() - // Reinitialize the chart - this.initChart() - } - - // Function to initialize the dark mode observer - initDarkModeObserver() { - this.darkModeObserver = new MutationObserver(() => { - this.refreshChart() - }) - this.darkModeObserver.observe(document.documentElement, { attributeFilter: ['class'] }) - } - - // Function to merge the options with the defaults - mergeOptionsWithDefaults() { - return { - ...this.optionsValue, - data: { - ...this.optionsValue.data, - datasets: this.optionsValue.data.datasets.map((dataset) => { - return { - ...this.defaultThemeColor(), - ...dataset, - } - }) - } - } - } -} diff --git a/docs/app/javascript/controllers/ruby_ui/chart_controller.js b/docs/app/javascript/controllers/ruby_ui/chart_controller.js new file mode 120000 index 000000000..3d3f046c3 --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/chart_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/chart/chart_controller.js \ No newline at end of file diff --git a/docs/app/javascript/controllers/ruby_ui/checkbox_group_controller.js b/docs/app/javascript/controllers/ruby_ui/checkbox_group_controller.js deleted file mode 100644 index 546167ad2..000000000 --- a/docs/app/javascript/controllers/ruby_ui/checkbox_group_controller.js +++ /dev/null @@ -1,21 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; - -export default class extends Controller { - static targets = ["checkbox"]; - - connect() { - this.#handleRequired(); - } - - onChange() { - this.#handleRequired(); - } - - #handleRequired() { - if (!this.element.hasAttribute("data-required")) return; - - const checked = this.checkboxTargets.some(({ checked }) => checked); - - this.checkboxTargets.forEach((checkbox) => (checkbox.required = !checked)); - } -} diff --git a/docs/app/javascript/controllers/ruby_ui/checkbox_group_controller.js b/docs/app/javascript/controllers/ruby_ui/checkbox_group_controller.js new file mode 120000 index 000000000..1ecb6e856 --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/checkbox_group_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/checkbox/checkbox_group_controller.js \ No newline at end of file diff --git a/docs/app/javascript/controllers/ruby_ui/clipboard_controller.js b/docs/app/javascript/controllers/ruby_ui/clipboard_controller.js deleted file mode 100644 index 00c6f5b3b..000000000 --- a/docs/app/javascript/controllers/ruby_ui/clipboard_controller.js +++ /dev/null @@ -1,54 +0,0 @@ -import { Controller } from "@hotwired/stimulus" -import { computePosition, flip, shift } from "@floating-ui/dom"; - -// Connects to data-controller="accordion" -export default class extends Controller { - static targets = ['trigger', 'source', 'successPopover', 'errorPopover'] - static values = { - options: { - type: Object, - default: {}, - }, - } - - copy() { - let sourceElement = this.sourceTarget.children[0]; - if (!sourceElement) { - this.showErrorPopover(); - return; - } - let textToCopy = sourceElement.tagName === 'INPUT' ? sourceElement.value : sourceElement.innerText; - navigator.clipboard.writeText(textToCopy).then(() => { - this.#showSuccessPopover(); - }).catch(() => { - this.#showErrorPopover(); - }) - } - - onClickOutside() { - if (!this.successPopoverTarget.classList.contains("hidden")) this.successPopoverTarget.classList.add("hidden"); - if (!this.errorPopoverTarget.classList.contains("hidden")) this.errorPopoverTarget.classList.add("hidden"); - } - - #computeTooltip(popoverElement) { - computePosition(this.triggerTarget, popoverElement, { - placement: this.optionsValue.placement || "top", - middleware: [flip(), shift()], - }).then(({ x, y }) => { - Object.assign(popoverElement.style, { - left: `${x}px`, - top: `${y}px`, - }); - }); - } - - #showSuccessPopover() { - this.#computeTooltip(this.successPopoverTarget); - this.successPopoverTarget.classList.remove("hidden"); - } - - #showErrorPopover() { - this.#computeTooltip(this.errorPopoverTarget); - this.errorPopoverTarget.classList.remove("hidden"); - } -} diff --git a/docs/app/javascript/controllers/ruby_ui/clipboard_controller.js b/docs/app/javascript/controllers/ruby_ui/clipboard_controller.js new file mode 120000 index 000000000..c8d776c84 --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/clipboard_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/clipboard/clipboard_controller.js \ No newline at end of file diff --git a/docs/app/javascript/controllers/ruby_ui/collapsible_controller.js b/docs/app/javascript/controllers/ruby_ui/collapsible_controller.js deleted file mode 100644 index cb367da3e..000000000 --- a/docs/app/javascript/controllers/ruby_ui/collapsible_controller.js +++ /dev/null @@ -1,47 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -// Connects to data-controller="accordion" -export default class extends Controller { - static targets = ['content'] - static values = { - open: { - type: Boolean, - default: false, - }, - } - - connect() { - // Set the initial state of the accordion - this.openValue ? this.open() : this.close() - } - - // Toggle the 'open' value - toggle() { - this.openValue = !this.openValue - } - - // Handle changes in the 'open' value - openValueChanged(isOpen, wasOpen) { - if (isOpen) { - this.open() - } else { - this.close() - } - } - - // Open the accordion content - open() { - if (this.hasContentTarget) { - this.contentTarget.classList.remove('hidden') - this.openValue = true - } - } - - // Close the accordion content - close() { - if (this.hasContentTarget) { - this.contentTarget.classList.add('hidden') - this.openValue = false - } - } -} diff --git a/docs/app/javascript/controllers/ruby_ui/collapsible_controller.js b/docs/app/javascript/controllers/ruby_ui/collapsible_controller.js new file mode 120000 index 000000000..bb88bed84 --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/collapsible_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/collapsible/collapsible_controller.js \ No newline at end of file diff --git a/docs/app/javascript/controllers/ruby_ui/combobox_controller.js b/docs/app/javascript/controllers/ruby_ui/combobox_controller.js deleted file mode 100644 index d19327723..000000000 --- a/docs/app/javascript/controllers/ruby_ui/combobox_controller.js +++ /dev/null @@ -1,191 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; -import { computePosition, autoUpdate, offset, flip } from "@floating-ui/dom"; - -// Connects to data-controller="ruby-ui--combobox" -export default class extends Controller { - static values = { - term: String - } - - static targets = [ - "input", - "toggleAll", - "popover", - "item", - "emptyState", - "searchInput", - "trigger", - "triggerContent" - ] - - selectedItemIndex = null - - connect() { - this.updateTriggerContent() - } - - disconnect() { - if (this.cleanup) { this.cleanup() } - } - - handlePopoverToggle(event) { - // Keep ariaExpanded in sync with the actual popover state - this.triggerTarget.ariaExpanded = event.newState === 'open' ? 'true' : 'false' - } - - inputChanged(e) { - this.updateTriggerContent() - - if (e.target.type == "radio") { - this.closePopover() - } - - if (this.hasToggleAllTarget && !e.target.checked) { - this.toggleAllTarget.checked = false - } - } - - inputContent(input) { - return input.dataset.text || input.parentElement.textContent - } - - toggleAllItems() { - const isChecked = this.toggleAllTarget.checked - this.inputTargets.forEach(input => input.checked = isChecked) - this.updateTriggerContent() - } - - updateTriggerContent() { - const checkedInputs = this.inputTargets.filter(input => input.checked) - - if (checkedInputs.length === 0) { - this.triggerContentTarget.innerText = this.triggerTarget.dataset.placeholder - } else if (this.termValue && checkedInputs.length > 1) { - this.triggerContentTarget.innerText = `${checkedInputs.length} ${this.termValue}` - } else { - this.triggerContentTarget.innerText = checkedInputs.map((input) => this.inputContent(input)).join(", ") - } - } - - togglePopover(event) { - event.preventDefault() - - if (this.triggerTarget.ariaExpanded === "true") { - this.closePopover() - } else { - this.openPopover(event) - } - } - - openPopover(event) { - if (event) event.preventDefault() - - this.updatePopoverPosition() - this.updatePopoverWidth() - this.triggerTarget.ariaExpanded = "true" - this.selectedItemIndex = null - this.itemTargets.forEach(item => item.ariaCurrent = "false") - this.popoverTarget.showPopover() - } - - closePopover() { - this.triggerTarget.ariaExpanded = "false" - this.popoverTarget.hidePopover() - } - - filterItems(e) { - if (["ArrowDown", "ArrowUp", "Tab", "Enter"].includes(e.key)) { - return - } - - const filterTerm = this.searchInputTarget.value.toLowerCase() - - if (this.hasToggleAllTarget) { - if (filterTerm) this.toggleAllTarget.parentElement.classList.add("hidden") - else this.toggleAllTarget.parentElement.classList.remove("hidden") - } - - let resultCount = 0 - - this.selectedItemIndex = null - - this.inputTargets.forEach((input) => { - const text = this.inputContent(input).toLowerCase() - - if (text.indexOf(filterTerm) > -1) { - input.parentElement.classList.remove("hidden") - resultCount++ - } else { - input.parentElement.classList.add("hidden") - } - }) - - this.emptyStateTarget.classList.toggle("hidden", resultCount !== 0) - } - - keyDownPressed() { - if (this.selectedItemIndex !== null) { - this.selectedItemIndex++ - } else { - this.selectedItemIndex = 0 - } - - this.focusSelectedInput() - } - - keyUpPressed() { - if (this.selectedItemIndex !== null) { - this.selectedItemIndex-- - } else { - this.selectedItemIndex = -1 - } - - this.focusSelectedInput() - } - - focusSelectedInput() { - const visibleInputs = this.inputTargets.filter(input => !input.parentElement.classList.contains("hidden")) - - this.wrapSelectedInputIndex(visibleInputs.length) - - visibleInputs.forEach((input, index) => { - if (index == this.selectedItemIndex) { - input.parentElement.ariaCurrent = "true" - input.parentElement.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }) - } else { - input.parentElement.ariaCurrent = "false" - } - }) - } - - keyEnterPressed(event) { - event.preventDefault() - const option = this.itemTargets.find(item => item.ariaCurrent === "true") - - if (option) { - option.click() - } - } - - wrapSelectedInputIndex(length) { - this.selectedItemIndex = ((this.selectedItemIndex % length) + length) % length - } - - updatePopoverPosition() { - this.cleanup = autoUpdate(this.triggerTarget, this.popoverTarget, () => { - computePosition(this.triggerTarget, this.popoverTarget, { - placement: 'bottom-start', - middleware: [offset(4), flip()], - }).then(({ x, y }) => { - Object.assign(this.popoverTarget.style, { - left: `${x}px`, - top: `${y}px`, - }); - }); - }); - } - - updatePopoverWidth() { - this.popoverTarget.style.width = `${this.triggerTarget.offsetWidth}px` - } -} diff --git a/docs/app/javascript/controllers/ruby_ui/combobox_controller.js b/docs/app/javascript/controllers/ruby_ui/combobox_controller.js new file mode 120000 index 000000000..cc2bbb467 --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/combobox_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/combobox/combobox_controller.js \ No newline at end of file diff --git a/docs/app/javascript/controllers/ruby_ui/command_controller.js b/docs/app/javascript/controllers/ruby_ui/command_controller.js deleted file mode 100644 index 91b80495d..000000000 --- a/docs/app/javascript/controllers/ruby_ui/command_controller.js +++ /dev/null @@ -1,126 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; -import Fuse from "fuse.js"; - -// Connects to data-controller="ruby-ui--command" -export default class extends Controller { - static targets = ["input", "group", "item", "empty"]; - - connect() { - this.selectedIndex = -1; - - if (!this.hasInputTarget) { - return; - } - - this.inputTarget.focus(); - this.searchIndex = this.buildSearchIndex(); - this.toggleVisibility(this.emptyTargets, false); - } - - dismiss() { - // allow scroll on body - document.body.classList.remove("overflow-hidden"); - // remove the element - this.element.remove(); - } - - focusInput() { - this.inputTarget?.focus(); - } - - filter(e) { - // Deselect any previously selected item - this.deselectAll(); - - const query = e.target.value.toLowerCase(); - if (query.length === 0) { - this.resetVisibility(); - return; - } - - this.toggleVisibility(this.itemTargets, false); - - const results = this.searchIndex.search(query); - results.forEach((result) => - this.toggleVisibility([result.item.element], true), - ); - - this.toggleVisibility(this.emptyTargets, results.length === 0); - this.updateGroupVisibility(); - } - - toggleVisibility(elements, isVisible) { - elements.forEach((el) => el.classList.toggle("hidden", !isVisible)); - } - - updateGroupVisibility() { - this.groupTargets.forEach((group) => { - const hasVisibleItems = - group.querySelectorAll( - "[data-ruby-ui--command-target='item']:not(.hidden)", - ).length > 0; - this.toggleVisibility([group], hasVisibleItems); - }); - } - - resetVisibility() { - this.toggleVisibility(this.itemTargets, true); - this.toggleVisibility(this.groupTargets, true); - this.toggleVisibility(this.emptyTargets, false); - } - - buildSearchIndex() { - const options = { - keys: ["value"], - threshold: 0.2, - includeMatches: true, - }; - const items = this.itemTargets.map((el) => ({ - value: el.dataset.value, - element: el, - })); - return new Fuse(items, options); - } - - handleKeydown(e) { - const visibleItems = this.itemTargets.filter( - (item) => !item.classList.contains("hidden"), - ); - if (e.key === "ArrowDown") { - e.preventDefault(); - this.updateSelectedItem(visibleItems, 1); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - this.updateSelectedItem(visibleItems, -1); - } else if (e.key === "Enter" && this.selectedIndex !== -1) { - e.preventDefault(); - visibleItems[this.selectedIndex].click(); - } - } - - updateSelectedItem(visibleItems, direction) { - if (this.selectedIndex >= 0) { - this.toggleAriaSelected(visibleItems[this.selectedIndex], false); - } - - this.selectedIndex += direction; - - // Ensure the selected index is within the bounds of the visible items - if (this.selectedIndex < 0) { - this.selectedIndex = visibleItems.length - 1; - } else if (this.selectedIndex >= visibleItems.length) { - this.selectedIndex = 0; - } - - this.toggleAriaSelected(visibleItems[this.selectedIndex], true); - } - - toggleAriaSelected(element, isSelected) { - element.setAttribute("aria-selected", isSelected.toString()); - } - - deselectAll() { - this.itemTargets.forEach((item) => this.toggleAriaSelected(item, false)); - this.selectedIndex = -1; - } -} diff --git a/docs/app/javascript/controllers/ruby_ui/command_controller.js b/docs/app/javascript/controllers/ruby_ui/command_controller.js new file mode 120000 index 000000000..5f7392bc0 --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/command_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/command/command_controller.js \ No newline at end of file diff --git a/docs/app/javascript/controllers/ruby_ui/command_dialog_controller.js b/docs/app/javascript/controllers/ruby_ui/command_dialog_controller.js deleted file mode 100644 index 7763a79e1..000000000 --- a/docs/app/javascript/controllers/ruby_ui/command_dialog_controller.js +++ /dev/null @@ -1,34 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; - -// Connects to data-controller="ruby-ui--command-dialog" -export default class extends Controller { - static targets = ["content"]; - static outlets = ["ruby-ui--command"]; - - rubyUiCommandOutletConnected(controller) { - this.openOutlet = controller; - } - - rubyUiCommandOutletDisconnected() { - this.openOutlet = null; - } - - open(e) { - if (e) { - e.preventDefault(); - } - - if (!this.hasContentTarget) { - return; - } - - if (this.openOutlet) { - this.openOutlet.focusInput(); - return; - } - - document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML); - // prevent scroll on body - document.body.classList.add("overflow-hidden"); - } -} diff --git a/docs/app/javascript/controllers/ruby_ui/command_dialog_controller.js b/docs/app/javascript/controllers/ruby_ui/command_dialog_controller.js new file mode 120000 index 000000000..1501aef82 --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/command_dialog_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/command/command_dialog_controller.js \ No newline at end of file diff --git a/docs/app/javascript/controllers/ruby_ui/context_menu_controller.js b/docs/app/javascript/controllers/ruby_ui/context_menu_controller.js deleted file mode 100644 index ebc1f20a4..000000000 --- a/docs/app/javascript/controllers/ruby_ui/context_menu_controller.js +++ /dev/null @@ -1,156 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; -import { - computePosition, - flip, - shift, - offset, - autoUpdate, -} from "@floating-ui/dom"; - -export default class extends Controller { - static targets = ["trigger", "content", "menuItem"]; - static values = { - open: { type: Boolean, default: false }, - options: { type: Object, default: {} }, - // make content width match the trigger element (true/false) - matchWidth: { type: Boolean, default: false }, - }; - - connect() { - this.cleanup = null; - this.selectedIndex = -1; - this.boundHandleKeydown = this.handleKeydown.bind(this); - } - - disconnect() { - this.hide(); - } - - handleContextMenu(event) { - event.preventDefault(); - this.open(); - } - - open() { - this.openValue = true; - this.contentTarget.classList.remove("hidden"); - this.contentTarget.dataset.state = "open"; - if (this.matchWidthValue) { - this.contentTarget.style.width = `${this.triggerTarget.offsetWidth}px`; - } - this.addEventListeners(); - this.updatePosition(); - } - - close() { - this.hide(); - } - - hide() { - if (!this.openValue) return; - this.openValue = false; - this.contentTarget.classList.add("hidden"); - this.contentTarget.dataset.state = "closed"; - this.removeEventListeners(); - this.deselectAll(); - if (this.cleanup) { - this.cleanup(); - this.cleanup = null; - } - } - - updatePosition() { - if (this.cleanup) this.cleanup(); - - this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => { - computePosition(this.triggerTarget, this.contentTarget, { - placement: this.optionsValue.placement || "bottom-start", - middleware: [offset(4), flip(), shift({ padding: 8 })], - }).then(({ x, y, placement }) => { - Object.assign(this.contentTarget.style, { - left: `${x}px`, - top: `${y}px`, - }); - this.contentTarget.dataset.side = placement.split("-")[0]; - }); - }); - } - - addEventListeners() { - document.addEventListener("keydown", this.boundHandleKeydown); - document.addEventListener("click", this.handleOutsidePointer); - // A right-click outside should dismiss this menu and let the native - // context menu (or another trigger's menu) take over. - document.addEventListener("contextmenu", this.handleOutsidePointer); - } - - removeEventListeners() { - document.removeEventListener("keydown", this.boundHandleKeydown); - document.removeEventListener("click", this.handleOutsidePointer); - document.removeEventListener("contextmenu", this.handleOutsidePointer); - } - - handleOutsidePointer = (event) => { - if (!this.element.contains(event.target)) { - this.hide(); - } - }; - - handleKeydown(e) { - if (e.key === "Escape") { - e.preventDefault(); - this.hide(); - return; - } - - if (this.menuItemTargets.length === 0) return; - - if (e.key === "ArrowDown") { - e.preventDefault(); - this.updateSelectedItem(1); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - this.updateSelectedItem(-1); - } else if (e.key === "Enter" && this.selectedIndex !== -1) { - e.preventDefault(); - this.menuItemTargets[this.selectedIndex].click(); - } - } - - updateSelectedItem(direction) { - this.menuItemTargets.forEach((item, index) => { - if (item.getAttribute("aria-selected") === "true") { - this.selectedIndex = index; - } - }); - - if (this.selectedIndex >= 0) { - this.toggleAriaSelected(this.menuItemTargets[this.selectedIndex], false); - } - - this.selectedIndex += direction; - - if (this.selectedIndex < 0) { - this.selectedIndex = this.menuItemTargets.length - 1; - } else if (this.selectedIndex >= this.menuItemTargets.length) { - this.selectedIndex = 0; - } - - this.toggleAriaSelected(this.menuItemTargets[this.selectedIndex], true); - } - - toggleAriaSelected(element, isSelected) { - if (isSelected) { - element.setAttribute("aria-selected", "true"); - } else { - element.removeAttribute("aria-selected"); - } - } - - deselectAll() { - this.menuItemTargets.forEach((item) => - this.toggleAriaSelected(item, false) - ); - this.selectedIndex = -1; - } -} diff --git a/docs/app/javascript/controllers/ruby_ui/context_menu_controller.js b/docs/app/javascript/controllers/ruby_ui/context_menu_controller.js new file mode 120000 index 000000000..5edbcc475 --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/context_menu_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/context_menu/context_menu_controller.js \ No newline at end of file diff --git a/docs/app/javascript/controllers/ruby_ui/data_table_column_visibility_controller.js b/docs/app/javascript/controllers/ruby_ui/data_table_column_visibility_controller.js deleted file mode 100644 index c4b3f629c..000000000 --- a/docs/app/javascript/controllers/ruby_ui/data_table_column_visibility_controller.js +++ /dev/null @@ -1,24 +0,0 @@ -// app/javascript/controllers/ruby_ui/data_table_column_visibility_controller.js -import { Controller } from "@hotwired/stimulus"; - -export default class extends Controller { - connect() { - this.element - .querySelectorAll("input[type=checkbox][data-column-key]") - .forEach((checkbox) => - this._apply(checkbox.dataset.columnKey, checkbox.checked) - ); - } - - toggle(event) { - this._apply(event.target.dataset.columnKey, event.target.checked); - } - - _apply(key, visible) { - const root = this.element.closest('[data-controller~="ruby-ui--data-table"]'); - if (!root) return; - root - .querySelectorAll(`[data-column="${key}"]`) - .forEach((el) => el.classList.toggle("hidden", !visible)); - } -} diff --git a/docs/app/javascript/controllers/ruby_ui/data_table_column_visibility_controller.js b/docs/app/javascript/controllers/ruby_ui/data_table_column_visibility_controller.js new file mode 120000 index 000000000..8d5991005 --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/data_table_column_visibility_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/data_table/data_table_column_visibility_controller.js \ No newline at end of file diff --git a/docs/app/javascript/controllers/ruby_ui/data_table_controller.js b/docs/app/javascript/controllers/ruby_ui/data_table_controller.js deleted file mode 100644 index 1ffb8fb2e..000000000 --- a/docs/app/javascript/controllers/ruby_ui/data_table_controller.js +++ /dev/null @@ -1,57 +0,0 @@ -// app/javascript/controllers/ruby_ui/data_table_controller.js -import { Controller } from "@hotwired/stimulus"; - -export default class extends Controller { - static targets = [ - "selectAll", - "rowCheckbox", - "selectionSummary", - "selectionBar", - "bulkActions", - ]; - - connect() { - this.updateState(); - } - - toggleAll(event) { - const checked = event.target.checked; - this.rowCheckboxTargets.forEach((cb) => { - cb.checked = checked; - }); - this.updateState(); - } - - toggleRow() { - this.updateState(); - } - - toggleRowDetail(event) { - const button = event.currentTarget; - const id = button.getAttribute("aria-controls"); - if (!id) return; - const target = document.getElementById(id); - if (!target) return; - const expanded = button.getAttribute("aria-expanded") === "true"; - button.setAttribute("aria-expanded", String(!expanded)); - target.classList.toggle("hidden", expanded); - } - - updateState() { - const total = this.rowCheckboxTargets.length; - const selected = this.rowCheckboxTargets.filter((cb) => cb.checked).length; - - if (this.hasSelectAllTarget) { - this.selectAllTarget.checked = total > 0 && selected === total; - this.selectAllTarget.indeterminate = selected > 0 && selected < total; - } - - if (this.hasSelectionSummaryTarget) { - this.selectionSummaryTarget.textContent = `${selected} of ${total} row(s) selected.`; - } - - if (this.hasBulkActionsTarget) { - this.bulkActionsTarget.classList.toggle("hidden", selected === 0); - } - } -} diff --git a/docs/app/javascript/controllers/ruby_ui/data_table_controller.js b/docs/app/javascript/controllers/ruby_ui/data_table_controller.js new file mode 120000 index 000000000..98151fe76 --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/data_table_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/data_table/data_table_controller.js \ No newline at end of file diff --git a/docs/app/javascript/controllers/ruby_ui/data_table_search_controller.js b/docs/app/javascript/controllers/ruby_ui/data_table_search_controller.js deleted file mode 100644 index 0dc4101c5..000000000 --- a/docs/app/javascript/controllers/ruby_ui/data_table_search_controller.js +++ /dev/null @@ -1,62 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; - -// Module-level map survives controller disconnect/connect across Turbo Frame swaps. -// Keyed by the search form's action URL. -const PENDING_FOCUS = new Map(); - -export default class extends Controller { - static values = { delay: { type: Number, default: 300 } }; - - connect() { - this.timer = null; - this.beforeFrameRender = this.captureBeforeRender.bind(this); - document.addEventListener("turbo:before-frame-render", this.beforeFrameRender); - // New instance after a Turbo Frame swap — check for captured state. - this.restoreIfPending(); - } - - disconnect() { - clearTimeout(this.timer); - document.removeEventListener("turbo:before-frame-render", this.beforeFrameRender); - } - - submit(event) { - if (event && event.type !== "input") return; - clearTimeout(this.timer); - if (this.delayValue <= 0) return; - this.timer = setTimeout(() => this.element.requestSubmit(), this.delayValue); - } - - captureBeforeRender() { - const input = this.input(); - if (!input || document.activeElement !== input) return; - PENDING_FOCUS.set(this.key(), { - selectionStart: input.selectionStart, - selectionEnd: input.selectionEnd - }); - } - - restoreIfPending() { - const state = PENDING_FOCUS.get(this.key()); - if (!state) return; - PENDING_FOCUS.delete(this.key()); - const input = this.input(); - if (!input) return; - input.focus(); - const len = input.value.length; - try { - input.setSelectionRange( - Math.min(state.selectionStart ?? len, len), - Math.min(state.selectionEnd ?? len, len) - ); - } catch (e) {} - } - - input() { - return this.element.querySelector('input[type="search"]'); - } - - key() { - return this.element.action || "_"; - } -} diff --git a/docs/app/javascript/controllers/ruby_ui/data_table_search_controller.js b/docs/app/javascript/controllers/ruby_ui/data_table_search_controller.js new file mode 120000 index 000000000..9399c68fb --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/data_table_search_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/data_table/data_table_search_controller.js \ No newline at end of file diff --git a/docs/app/javascript/controllers/ruby_ui/dialog_controller.js b/docs/app/javascript/controllers/ruby_ui/dialog_controller.js deleted file mode 100644 index 7b7e368cc..000000000 --- a/docs/app/javascript/controllers/ruby_ui/dialog_controller.js +++ /dev/null @@ -1,44 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -// Connects to data-controller="ruby-ui--dialog" -export default class extends Controller { - static targets = ["dialog"] - static values = { - open: { - type: Boolean, - default: false - }, - } - - connect() { - this.dialogTarget.addEventListener("close", this.handleClose) - if (this.openValue) { - this.open() - } - } - - disconnect() { - this.dialogTarget.removeEventListener("close", this.handleClose) - document.body.classList.remove("overflow-hidden") - } - - open(e) { - e?.preventDefault() - this.dialogTarget.showModal() - document.body.classList.add("overflow-hidden") - } - - dismiss() { - this.dialogTarget.close() - } - - backdropClick(e) { - if (e.target === this.dialogTarget) { - this.dismiss() - } - } - - handleClose = () => { - document.body.classList.remove("overflow-hidden") - } -} diff --git a/docs/app/javascript/controllers/ruby_ui/dialog_controller.js b/docs/app/javascript/controllers/ruby_ui/dialog_controller.js new file mode 120000 index 000000000..e9e0a1689 --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/dialog_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/dialog/dialog_controller.js \ No newline at end of file diff --git a/docs/app/javascript/controllers/ruby_ui/dropdown_menu_controller.js b/docs/app/javascript/controllers/ruby_ui/dropdown_menu_controller.js deleted file mode 100644 index 6479c8442..000000000 --- a/docs/app/javascript/controllers/ruby_ui/dropdown_menu_controller.js +++ /dev/null @@ -1,153 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; -import { - computePosition, - flip, - shift, - offset, - autoUpdate, -} from "@floating-ui/dom"; - -export default class extends Controller { - static targets = ["trigger", "content", "menuItem"]; - static values = { - open: { - type: Boolean, - default: false, - }, - options: { - type: Object, - default: {}, - }, - }; - - connect() { - this.boundHandleKeydown = this.#handleKeydown.bind(this); // Bind the function so we can remove it later - this.selectedIndex = -1; - - this.#setupAutoUpdate(); - } - - disconnect() { - if (this.autoUpdateCleanup) { - this.autoUpdateCleanup(); - } - } - - #setupAutoUpdate() { - this.autoUpdateCleanup = autoUpdate( - this.triggerTarget, - this.contentTarget, - this.#computeTooltip.bind(this), - ); - } - - #computeTooltip() { - computePosition(this.triggerTarget, this.contentTarget, { - placement: this.optionsValue.placement || "bottom", - middleware: [flip(), shift(), offset(8)], - strategy: this.optionsValue.strategy || "absolute", - }).then(({ x, y }) => { - Object.assign(this.contentTarget.style, { - left: `${x}px`, - top: `${y}px`, - }); - }); - } - - onClickOutside(event) { - if (!this.openValue) return; - if (this.element.contains(event.target)) return; - - event.preventDefault(); - this.close(); - } - - toggle() { - this.contentTarget.classList.contains("hidden") - ? this.#open() - : this.close(); - } - - #open() { - this.openValue = true; - this.#deselectAll(); - this.#addEventListeners(); - this.#computeTooltip(); - // Lift the open menu above sibling dropdowns/elements. The container has no - // static z-index, so closed siblings stack in normal flow and never cover it. - this.element.style.zIndex = "50"; - this.contentTarget.classList.remove("hidden"); - } - - close() { - this.openValue = false; - this.#removeEventListeners(); - this.element.style.zIndex = ""; - this.contentTarget.classList.add("hidden"); - } - - #handleKeydown(e) { - // return if no menu items (one line fix for) - if (this.menuItemTargets.length === 0) { - return; - } - - if (e.key === "ArrowDown") { - e.preventDefault(); - this.#updateSelectedItem(1); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - this.#updateSelectedItem(-1); - } else if (e.key === "Enter" && this.selectedIndex !== -1) { - e.preventDefault(); - this.menuItemTargets[this.selectedIndex].click(); - } - } - - #updateSelectedItem(direction) { - // Check if any of the menuItemTargets have aria-selected="true" and set the selectedIndex to that index - this.menuItemTargets.forEach((item, index) => { - if (item.getAttribute("aria-selected") === "true") { - this.selectedIndex = index; - } - }); - - if (this.selectedIndex >= 0) { - this.#toggleAriaSelected(this.menuItemTargets[this.selectedIndex], false); - } - - this.selectedIndex += direction; - - if (this.selectedIndex < 0) { - this.selectedIndex = this.menuItemTargets.length - 1; - } else if (this.selectedIndex >= this.menuItemTargets.length) { - this.selectedIndex = 0; - } - - this.#toggleAriaSelected(this.menuItemTargets[this.selectedIndex], true); - } - - #toggleAriaSelected(element, isSelected) { - // Add or remove attribute - if (isSelected) { - element.setAttribute("aria-selected", "true"); - } else { - element.removeAttribute("aria-selected"); - } - } - - #deselectAll() { - this.menuItemTargets.forEach((item) => - this.#toggleAriaSelected(item, false), - ); - this.selectedIndex = -1; - } - - #addEventListeners() { - document.addEventListener("keydown", this.boundHandleKeydown); - } - - #removeEventListeners() { - document.removeEventListener("keydown", this.boundHandleKeydown); - } -} diff --git a/docs/app/javascript/controllers/ruby_ui/dropdown_menu_controller.js b/docs/app/javascript/controllers/ruby_ui/dropdown_menu_controller.js new file mode 120000 index 000000000..b50d2a7ab --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/dropdown_menu_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/dropdown_menu/dropdown_menu_controller.js \ No newline at end of file diff --git a/docs/app/javascript/controllers/ruby_ui/form_field_controller.js b/docs/app/javascript/controllers/ruby_ui/form_field_controller.js deleted file mode 100644 index b7af9394e..000000000 --- a/docs/app/javascript/controllers/ruby_ui/form_field_controller.js +++ /dev/null @@ -1,61 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; - -export default class extends Controller { - static targets = ["input", "error"]; - static values = { shouldValidate: false }; - - connect() { - if (this.hasErrorTarget) { - if (this.errorTarget.textContent) { - this.shouldValidateValue = true; - } else { - this.errorTarget.classList.add("hidden"); - } - } - } - - onInvalid(error) { - error.preventDefault(); - - this.shouldValidateValue = true; - this.#setErrorMessage(); - } - - onInput() { - this.#setErrorMessage(); - } - - onChange() { - this.#setErrorMessage(); - } - - #setErrorMessage() { - if (!this.shouldValidateValue) return; - - if (this.inputTarget.validity.valid) { - this.errorTarget.textContent = ""; - this.errorTarget.classList.add("hidden"); - } else { - this.errorTarget.textContent = this.#getValidationMessage(); - this.errorTarget.classList.remove("hidden"); - } - } - - #getValidationMessage() { - let errorMessage; - - const { validity, dataset, validationMessage } = this.inputTarget; - - if (validity.tooLong) errorMessage = dataset.tooLong; - if (validity.tooShort) errorMessage = dataset.tooShort; - if (validity.badInput) errorMessage = dataset.badInput; - if (validity.typeMismatch) errorMessage = dataset.typeMismatch; - if (validity.stepMismatch) errorMessage = dataset.stepMismatch; - if (validity.valueMissing) errorMessage = dataset.valueMissing; - if (validity.rangeOverflow) errorMessage = dataset.rangeOverflow; - if (validity.rangeUnderflow) errorMessage = dataset.rangeUnderflow; - if (validity.patternMismatch) errorMessage = dataset.patternMismatch; - - return errorMessage || validationMessage; - } -} diff --git a/docs/app/javascript/controllers/ruby_ui/form_field_controller.js b/docs/app/javascript/controllers/ruby_ui/form_field_controller.js new file mode 120000 index 000000000..9e9405677 --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/form_field_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/form/form_field_controller.js \ No newline at end of file diff --git a/docs/app/javascript/controllers/ruby_ui/hover_card_controller.js b/docs/app/javascript/controllers/ruby_ui/hover_card_controller.js deleted file mode 100644 index 955117f08..000000000 --- a/docs/app/javascript/controllers/ruby_ui/hover_card_controller.js +++ /dev/null @@ -1,181 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; -import { - computePosition, - flip, - shift, - offset, - autoUpdate, -} from "@floating-ui/dom"; - -export default class extends Controller { - static targets = ["trigger", "content", "menuItem"]; - static values = { - open: { type: Boolean, default: false }, - options: { type: Object, default: {} }, - // make content width match the trigger element (true/false) - matchWidth: { type: Boolean, default: false }, - }; - - connect() { - this.openDelay = this.delayAt(0, 500); - this.closeDelay = this.delayAt(1, 250); - this.openTimeout = null; - this.closeTimeout = null; - this.cleanup = null; - this.selectedIndex = -1; - this.boundHandleKeydown = this.handleKeydown.bind(this); - this.addEventListeners(); - } - - disconnect() { - this.removeEventListeners(); - this.clearTimers(); - document.removeEventListener("keydown", this.boundHandleKeydown); - if (this.cleanup) { - this.cleanup(); - this.cleanup = null; - } - } - - // Supports the tippy-style `delay` option: a number or a [open, close] tuple. - delayAt(index, fallback) { - const delay = this.optionsValue.delay; - if (Array.isArray(delay)) return delay[index] ?? fallback; - if (typeof delay === "number") return delay; - return fallback; - } - - addEventListeners() { - this.triggerTarget.addEventListener("mouseenter", this.handleMouseEnter); - this.triggerTarget.addEventListener("mouseleave", this.handleMouseLeave); - this.triggerTarget.addEventListener("focusin", this.handleMouseEnter); - this.triggerTarget.addEventListener("focusout", this.handleMouseLeave); - this.contentTarget.addEventListener("mouseenter", this.handleMouseEnter); - this.contentTarget.addEventListener("mouseleave", this.handleMouseLeave); - this.contentTarget.addEventListener("focusin", this.handleMouseEnter); - this.contentTarget.addEventListener("focusout", this.handleMouseLeave); - } - - removeEventListeners() { - this.triggerTarget.removeEventListener("mouseenter", this.handleMouseEnter); - this.triggerTarget.removeEventListener("mouseleave", this.handleMouseLeave); - this.triggerTarget.removeEventListener("focusin", this.handleMouseEnter); - this.triggerTarget.removeEventListener("focusout", this.handleMouseLeave); - this.contentTarget.removeEventListener("mouseenter", this.handleMouseEnter); - this.contentTarget.removeEventListener("mouseleave", this.handleMouseLeave); - this.contentTarget.removeEventListener("focusin", this.handleMouseEnter); - this.contentTarget.removeEventListener("focusout", this.handleMouseLeave); - } - - handleMouseEnter = () => { - this.clearTimers(); - this.openTimeout = setTimeout(() => this.show(), this.openDelay); - }; - - handleMouseLeave = () => { - this.clearTimers(); - this.closeTimeout = setTimeout(() => this.hide(), this.closeDelay); - }; - - clearTimers() { - clearTimeout(this.openTimeout); - clearTimeout(this.closeTimeout); - } - - show() { - this.openValue = true; - this.contentTarget.classList.remove("hidden"); - this.contentTarget.dataset.state = "open"; - if (this.matchWidthValue) { - this.contentTarget.style.width = `${this.triggerTarget.offsetWidth}px`; - } - document.addEventListener("keydown", this.boundHandleKeydown); - this.updatePosition(); - } - - hide() { - this.openValue = false; - this.contentTarget.classList.add("hidden"); - this.contentTarget.dataset.state = "closed"; - document.removeEventListener("keydown", this.boundHandleKeydown); - this.deselectAll(); - if (this.cleanup) { - this.cleanup(); - this.cleanup = null; - } - } - - updatePosition() { - if (this.cleanup) this.cleanup(); - - this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => { - computePosition(this.triggerTarget, this.contentTarget, { - placement: this.optionsValue.placement || "bottom", - middleware: [offset(4), flip(), shift({ padding: 8 })], - }).then(({ x, y, placement }) => { - Object.assign(this.contentTarget.style, { - left: `${x}px`, - top: `${y}px`, - }); - this.contentTarget.dataset.side = placement.split("-")[0]; - }); - }); - } - - handleKeydown(e) { - if (e.key === "Escape") { - this.hide(); - return; - } - - if (this.menuItemTargets.length === 0) return; - - if (e.key === "ArrowDown") { - e.preventDefault(); - this.updateSelectedItem(1); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - this.updateSelectedItem(-1); - } else if (e.key === "Enter" && this.selectedIndex !== -1) { - e.preventDefault(); - this.menuItemTargets[this.selectedIndex].click(); - } - } - - updateSelectedItem(direction) { - this.menuItemTargets.forEach((item, index) => { - if (item.getAttribute("aria-selected") === "true") { - this.selectedIndex = index; - } - }); - - if (this.selectedIndex >= 0) { - this.toggleAriaSelected(this.menuItemTargets[this.selectedIndex], false); - } - - this.selectedIndex += direction; - - if (this.selectedIndex < 0) { - this.selectedIndex = this.menuItemTargets.length - 1; - } else if (this.selectedIndex >= this.menuItemTargets.length) { - this.selectedIndex = 0; - } - - this.toggleAriaSelected(this.menuItemTargets[this.selectedIndex], true); - } - - toggleAriaSelected(element, isSelected) { - if (isSelected) { - element.setAttribute("aria-selected", "true"); - } else { - element.removeAttribute("aria-selected"); - } - } - - deselectAll() { - this.menuItemTargets.forEach((item) => - this.toggleAriaSelected(item, false) - ); - this.selectedIndex = -1; - } -} diff --git a/docs/app/javascript/controllers/ruby_ui/hover_card_controller.js b/docs/app/javascript/controllers/ruby_ui/hover_card_controller.js new file mode 120000 index 000000000..88683f621 --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/hover_card_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/hover_card/hover_card_controller.js \ No newline at end of file diff --git a/docs/app/javascript/controllers/ruby_ui/input_otp_controller.js b/docs/app/javascript/controllers/ruby_ui/input_otp_controller.js deleted file mode 100644 index 45854327d..000000000 --- a/docs/app/javascript/controllers/ruby_ui/input_otp_controller.js +++ /dev/null @@ -1,128 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -// Connects to data-controller="ruby-ui--input-otp" -export default class extends Controller { - static targets = ["input", "slot"] - static values = { length: Number, charClass: String } - - connect() { - // A server-rendered value (prefilled from a previous submission, a - // validation error, etc.) may exceed length or contain characters that - // fail pattern. Sanitize it up front so the hidden slots never mask - // an invalid/oversized value that would otherwise still get submitted. - this.sanitizeValue() - this.paint() - this.boundOnSelectionChange = this.onSelectionChange.bind(this) - document.addEventListener("selectionchange", this.boundOnSelectionChange) - } - - disconnect() { - document.removeEventListener("selectionchange", this.boundOnSelectionChange) - } - - onInput() { - this.sanitizeValue() - const filtered = this.inputTarget.value - - this.normalizeSelection() - this.paint() - this.dispatch("input", { detail: { value: filtered } }) - if (filtered.length === this.lengthValue) { - this.dispatch("complete", { detail: { value: filtered } }) - } - } - - onFocus() { - const end = this.inputTarget.value.length - const start = Math.min(end, this.lengthValue - 1) - this.inputTarget.setSelectionRange(start, end) - this.paint() - } - - onBlur() { - this.paint() - } - - onKeydown(event) { - const moves = { ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 } - if (!(event.key in moves)) return - - event.preventDefault() - const current = this.inputTarget.selectionStart ?? 0 - const next = Math.min(Math.max(current + moves[event.key], 0), this.lengthValue - 1) - const hasChar = next < this.inputTarget.value.length - this.inputTarget.setSelectionRange(next, hasChar ? next + 1 : next) - this.paint() - } - - onPaste(event) { - event.preventDefault() - const pasted = this.filter(event.clipboardData.getData("text/plain")) - if (!pasted) return - - const start = this.inputTarget.selectionStart ?? 0 - const end = this.inputTarget.selectionEnd ?? start - const current = this.inputTarget.value - const merged = (current.slice(0, start) + pasted + current.slice(end)).slice(0, this.lengthValue) - - this.inputTarget.value = merged - const caret = Math.min(merged.length, this.lengthValue - 1) - this.inputTarget.setSelectionRange(caret, merged.length) - - this.paint() - this.dispatch("input", { detail: { value: merged } }) - if (merged.length === this.lengthValue) this.dispatch("complete", { detail: { value: merged } }) - } - - onSelectionChange() { - if (document.activeElement !== this.inputTarget) return - this.paint() - } - - // After typing, replacing, or deleting, the browser leaves a collapsed - // caret. If it landed on a slot that already has a character (not the - // true insert-mode end of the value), re-select that character as a - // 1-char range so the next keystroke replaces it instead of being - // silently dropped by the native maxlength/no-selection behavior. - normalizeSelection() { - const input = this.inputTarget - const value = input.value - const s = input.selectionStart - const e = input.selectionEnd - if (s === null || e === null || s !== e) return - - const isInsertMode = s === value.length && value.length < this.lengthValue - if (isInsertMode) return - - const index = Math.min(s, this.lengthValue - 1) - input.setSelectionRange(index, index < value.length ? index + 1 : index) - } - - filter(raw) { - const re = new RegExp(this.charClassValue) - return raw.split("").filter((char) => re.test(char)).join("") - } - - sanitizeValue() { - const filtered = this.filter(this.inputTarget.value).slice(0, this.lengthValue) - if (filtered !== this.inputTarget.value) this.inputTarget.value = filtered - } - - paint() { - const value = this.inputTarget.value - const isFocused = document.activeElement === this.inputTarget - const start = this.inputTarget.selectionStart ?? value.length - const end = this.inputTarget.selectionEnd ?? value.length - const activeIndex = Math.min(start, this.lengthValue - 1) - - this.slotTargets.forEach((slot) => { - const index = Number(slot.dataset.index) - const char = value[index] ?? "" - const isActive = isFocused && ((start === end && index === activeIndex) || (index >= start && index < end)) - - slot.textContent = char - slot.dataset.active = isActive ? "true" : "false" - slot.dataset.caret = isActive && char === "" ? "true" : "false" - }) - } -} diff --git a/docs/app/javascript/controllers/ruby_ui/input_otp_controller.js b/docs/app/javascript/controllers/ruby_ui/input_otp_controller.js new file mode 120000 index 000000000..20ad1ff61 --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/input_otp_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/input_otp/input_otp_controller.js \ No newline at end of file diff --git a/docs/app/javascript/controllers/ruby_ui/masked_input_controller.js b/docs/app/javascript/controllers/ruby_ui/masked_input_controller.js deleted file mode 100644 index dfea09454..000000000 --- a/docs/app/javascript/controllers/ruby_ui/masked_input_controller.js +++ /dev/null @@ -1,9 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; -import { MaskInput } from "maska"; - -// Connects to data-controller="ruby-ui--masked-input" -export default class extends Controller { - connect() { - new MaskInput(this.element) - } -} diff --git a/docs/app/javascript/controllers/ruby_ui/masked_input_controller.js b/docs/app/javascript/controllers/ruby_ui/masked_input_controller.js new file mode 120000 index 000000000..0c4098c35 --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/masked_input_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/masked_input/masked_input_controller.js \ No newline at end of file diff --git a/docs/app/javascript/controllers/ruby_ui/message_scroller_controller.js b/docs/app/javascript/controllers/ruby_ui/message_scroller_controller.js deleted file mode 100644 index 33905c64f..000000000 --- a/docs/app/javascript/controllers/ruby_ui/message_scroller_controller.js +++ /dev/null @@ -1,335 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; - -// Connects to data-controller="ruby-ui--message-scroller" -// -// A chat transcript scroller. Owns scroll state and behavior for a -// height-constrained message list: -// -// - autoScroll: follows the live edge while the reader is pinned to the -// bottom, and releases the moment they scroll, wheel, drag, or key away. -// - scrollAnchor: when a new anchored turn is appended, settles it near the -// top of the viewport keeping a peek of the previous turn above it. -// - defaultScrollPosition: where a freshly mounted transcript opens -// ("end", "start" or "last-anchor"). -// - preserveScrollOnPrepend: keeps the visible row fixed when older messages -// are loaded in above the current view. -// -// Public API (callable from other controllers/outlets or future -// streaming/ActionCable code): scrollToEnd(), scrollToStart(), -// scrollToMessage(id). New rows appended to the content target are picked up -// automatically via MutationObserver — no manual call needed. -export default class extends Controller { - static targets = ["viewport", "content", "button"]; - - static values = { - autoScroll: { type: Boolean, default: true }, - previousItemPeek: { type: Number, default: 64 }, - defaultPosition: { type: String, default: "end" }, - preserveOnPrepend: { type: Boolean, default: true }, - endThreshold: { type: Number, default: 32 }, - }; - - connect() { - // Reader is considered "following" the live edge until they move away. - this.following = true; - // True only while a programmatic scroll is in flight, so reader-intent - // handlers don't mistake our own scrolling for the reader's. - this.programmatic = false; - - this.onScroll = this.onScroll.bind(this); - this.onWheel = this.onWheel.bind(this); - this.onTouchStart = this.onTouchStart.bind(this); - this.onKeydown = this.onKeydown.bind(this); - - if (this.hasViewportTarget) { - this.viewportTarget.addEventListener("scroll", this.onScroll, { passive: true }); - this.viewportTarget.addEventListener("wheel", this.onWheel, { passive: true }); - this.viewportTarget.addEventListener("touchstart", this.onTouchStart, { passive: true }); - this.viewportTarget.addEventListener("keydown", this.onKeydown); - } - - if (this.hasContentTarget) { - // Announce streamed/added messages to assistive tech at a calm pace. - if (!this.contentTarget.hasAttribute("role")) { - this.contentTarget.setAttribute("role", "log"); - } - if (!this.contentTarget.hasAttribute("aria-relevant")) { - this.contentTarget.setAttribute("aria-relevant", "additions text"); - } - - this.observer = new MutationObserver((records) => this.onMutations(records)); - this.observer.observe(this.contentTarget, { - childList: true, - subtree: true, - characterData: true, - }); - } - - // Apply the opening position after layout settles. - requestAnimationFrame(() => { - this.applyDefaultPosition(); - this.updateButton(); - }); - } - - disconnect() { - if (this.hasViewportTarget) { - this.viewportTarget.removeEventListener("scroll", this.onScroll); - this.viewportTarget.removeEventListener("wheel", this.onWheel); - this.viewportTarget.removeEventListener("touchstart", this.onTouchStart); - this.viewportTarget.removeEventListener("keydown", this.onKeydown); - } - this.observer?.disconnect(); - if (this.animationFrame) cancelAnimationFrame(this.animationFrame); - } - - // --- Reader intent ------------------------------------------------------- - - onScroll() { - if (this.programmatic) return; - this.following = this.isAtEnd(); - this.updateButton(); - } - - // Any upward wheel is a deliberate move away from the live edge. - onWheel(event) { - if (event.deltaY < 0) this.release(); - } - - onTouchStart() { - // A touch that turns into an upward drag surfaces through onScroll; this - // just makes the release feel immediate when the reader grabs the list. - if (!this.isAtEnd()) this.release(); - } - - onKeydown(event) { - const navKeys = ["ArrowUp", "PageUp", "Home", "ArrowDown", "PageDown", "End", " "]; - if (navKeys.includes(event.key)) this.release(); - } - - release() { - if (this.programmatic) return; - this.following = false; - } - - // --- Mutations (new / prepended / streamed rows) ------------------------- - - onMutations(records) { - let appended = null; - let prependedHeight = 0; - let streamed = false; - const gap = this.rowGap(); - - for (const record of records) { - // Text streamed into an existing row (e.g. tokens) — not a new turn. - if (record.type === "characterData") { - streamed = true; - continue; - } - if (record.type !== "childList") continue; - // Only direct children of the content element are transcript rows. - // Markup inserted *inside* a message must not be mistaken for history. - if (record.target !== this.contentTarget) { - streamed = true; - continue; - } - for (const node of record.addedNodes) { - if (node.nodeType !== Node.ELEMENT_NODE) continue; - if (record.previousSibling === null && record.nextSibling !== null) { - // Inserted above existing rows → history prepend. Account for the - // flex row gap each prepended row introduces, or the preserved row - // drifts down by one gap per insertion. - prependedHeight += (node.offsetHeight || 0) + gap; - } else { - // Inserted at (or after) the end → new turn. - appended = node; - } - } - } - - if (prependedHeight > 0 && this.preserveOnPrependValue) { - // Keep the reader's current row fixed while history loads in above. - this.viewportTarget.scrollTop += prependedHeight; - } - - // Only move for new/streamed content while the reader is at the live edge. - // If they scrolled away, leave them there and let the button surface it. - const follow = this.autoScrollValue && this.following; - if (appended && follow) { - const anchor = appended.matches?.("[data-scroll-anchor]") - ? appended - : appended.querySelector?.("[data-scroll-anchor]"); - if (anchor) { - this.scrollToAnchor(anchor); - } else { - this.scrollToEnd(); - } - } else if (!appended && streamed && follow) { - // Text streamed into the last row. Stay pinned. - this.scrollToEnd("auto"); - } - - this.updateButton(); - } - - rowGap() { - if (!this.hasContentTarget) return 0; - const value = parseFloat(getComputedStyle(this.contentTarget).rowGap); - return Number.isFinite(value) ? value : 0; - } - - // --- Public scroll commands --------------------------------------------- - - scrollToEnd(behavior = "smooth") { - if (!this.hasViewportTarget) return; - this.following = true; - this.scrollTo(this.viewportTarget.scrollHeight, behavior); - } - - scrollToStart(behavior = "smooth") { - if (!this.hasViewportTarget) return; - this.following = false; - this.scrollTo(0, behavior); - } - - // Scroll a row with a matching messageId into view. Returns false when the - // target is not mounted. - scrollToMessage(id, behavior = "smooth") { - if (!this.hasContentTarget) return false; - const item = this.contentTarget.querySelector(`[data-message-id="${CSS.escape(id)}"]`); - if (!item) return false; - this.following = false; - this.scrollToAnchor(item, behavior); - return true; - } - - scrollToAnchor(item, behavior = "smooth") { - const top = Math.max(0, item.offsetTop - this.previousItemPeekValue); - this.scrollTo(top, behavior); - } - - // Bound to the scroll button's click action. Honors the button's - // data-direction so a start-direction button jumps to the start. - jump(event) { - if (event?.currentTarget?.dataset.direction === "start") { - this.scrollToStart(); - } else { - this.scrollToEnd(); - } - } - - // --- Internals ----------------------------------------------------------- - - // Native scrollTo({ behavior: "smooth" }) is unreliable on a contained, - // virtualized viewport, so we animate scrollTop ourselves with rAF. This - // gives us full control over completion (no scrollend dependency) and lets - // us honor reduced-motion. - scrollTo(top, behavior = "smooth") { - if (!this.hasViewportTarget) return; - const max = this.viewportTarget.scrollHeight - this.viewportTarget.clientHeight; - const target = Math.max(0, Math.min(top, max)); - - this.programmatic = true; - this.element.setAttribute("data-autoscrolling", ""); - this.viewportTarget.setAttribute("data-autoscrolling", ""); - if (this.animationFrame) cancelAnimationFrame(this.animationFrame); - - if (behavior === "auto" || this.prefersReducedMotion()) { - this.viewportTarget.scrollTop = target; - this.finishScroll(); - return; - } - - const start = this.viewportTarget.scrollTop; - const distance = target - start; - const duration = 300; - let startTime = null; - - const step = (now) => { - if (startTime === null) startTime = now; - const t = Math.min(1, (now - startTime) / duration); - // easeOutCubic - const eased = 1 - Math.pow(1 - t, 3); - this.viewportTarget.scrollTop = start + distance * eased; - if (t < 1) { - this.animationFrame = requestAnimationFrame(step); - } else { - this.finishScroll(); - } - }; - this.animationFrame = requestAnimationFrame(step); - } - - finishScroll() { - this.programmatic = false; - this.element.removeAttribute("data-autoscrolling"); - this.viewportTarget?.removeAttribute("data-autoscrolling"); - this.following = this.isAtEnd(); - this.updateButton(); - } - - prefersReducedMotion() { - return window.matchMedia("(prefers-reduced-motion: reduce)").matches; - } - - applyDefaultPosition() { - if (!this.hasViewportTarget) return; - const position = this.defaultPositionValue; - - if (position === "start") { - this.following = false; - this.viewportTarget.scrollTop = 0; - return; - } - - if (position === "last-anchor") { - // Stimulus' contentTarget getter throws when missing — guard explicitly. - const anchors = this.hasContentTarget - ? this.contentTarget.querySelectorAll("[data-scroll-anchor]") - : []; - const last = anchors[anchors.length - 1]; - // Fall back to the end when there's no anchor, or the last turn already - // fits in the viewport. - if (last && last.offsetTop - this.previousItemPeekValue > 0) { - this.following = false; - this.viewportTarget.scrollTop = Math.max(0, last.offsetTop - this.previousItemPeekValue); - this.updateButton(); - return; - } - } - - // Default: open at the live edge. - this.following = true; - this.viewportTarget.scrollTop = this.viewportTarget.scrollHeight; - } - - isAtEnd() { - if (!this.hasViewportTarget) return true; - const { scrollTop, clientHeight, scrollHeight } = this.viewportTarget; - return scrollHeight - (scrollTop + clientHeight) <= this.endThresholdValue; - } - - isAtStart() { - if (!this.hasViewportTarget) return true; - return this.viewportTarget.scrollTop <= this.endThresholdValue; - } - - hasOverflow() { - if (!this.hasViewportTarget) return false; - return this.viewportTarget.scrollHeight - this.viewportTarget.clientHeight > this.endThresholdValue; - } - - // Each button activates based on its own direction: an end button when the - // reader is away from the bottom, a start button when away from the top. - updateButton() { - if (!this.hasButtonTarget) return; - const overflow = this.hasOverflow(); - this.buttonTargets.forEach((button) => { - const toStart = button.dataset.direction === "start"; - const active = overflow && (toStart ? !this.isAtStart() : !this.isAtEnd()); - button.setAttribute("data-active", active ? "true" : "false"); - // Remove the inert button from the tab order so there are no ghost stops. - button.setAttribute("tabindex", active ? "0" : "-1"); - }); - } -} diff --git a/docs/app/javascript/controllers/ruby_ui/message_scroller_controller.js b/docs/app/javascript/controllers/ruby_ui/message_scroller_controller.js new file mode 120000 index 000000000..038f88a9a --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/message_scroller_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/message_scroller/message_scroller_controller.js \ No newline at end of file diff --git a/docs/app/javascript/controllers/ruby_ui/popover_controller.js b/docs/app/javascript/controllers/ruby_ui/popover_controller.js deleted file mode 100644 index 90e6f062f..000000000 --- a/docs/app/javascript/controllers/ruby_ui/popover_controller.js +++ /dev/null @@ -1,170 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; -import { - computePosition, - flip, - shift, - offset, - autoUpdate, -} from "@floating-ui/dom"; - -export default class extends Controller { - static targets = ["trigger", "content"]; - static values = { - open: { type: Boolean, default: false }, - options: { type: Object, default: {} }, - trigger: { type: String, default: "hover" }, - }; - - connect() { - this.closeTimeout = null; - this.cleanup = null; - this.addEventListeners(); - // openValue lives in the DOM, so a reconnect (frame swap, morph, moved element) - // arrives already open. Re-arm the parts that live on the controller instead of - // the markup — the keydown listener and the autoUpdate positioning. - if (this.openValue) this.showPopover(); - } - - // Teardown that cannot fail comes first: resolving a target throws once the - // element is gone, and Stimulus swallows that, skipping the rest of disconnect. - disconnect() { - clearTimeout(this.closeTimeout); - document.removeEventListener("keydown", this.handleKeydown); - document.removeEventListener("click", this.handleOutsideClick); - this.stopAutoUpdate(); - this.removeElementEventListeners(); - } - - addEventListeners() { - if (this.triggerValue === "hover") { - this.triggerTarget.addEventListener("mouseenter", this.handleMouseEnter); - this.triggerTarget.addEventListener("mouseleave", this.handleMouseLeave); - this.contentTarget.addEventListener("mouseenter", this.handleMouseEnter); - this.contentTarget.addEventListener("mouseleave", this.handleMouseLeave); - } else if (this.triggerValue === "click") { - this.triggerTarget.addEventListener("click", this.handleClick); - document.addEventListener("click", this.handleOutsideClick); - } - } - - // Each target is guarded on its own: losing one of them must not strand the - // listeners attached to the other. - removeElementEventListeners() { - if (this.hasTriggerTarget) { - this.triggerTarget.removeEventListener("mouseenter", this.handleMouseEnter); - this.triggerTarget.removeEventListener("mouseleave", this.handleMouseLeave); - this.triggerTarget.removeEventListener("click", this.handleClick); - } - - if (this.hasContentTarget) { - this.contentTarget.removeEventListener("mouseenter", this.handleMouseEnter); - this.contentTarget.removeEventListener("mouseleave", this.handleMouseLeave); - } - } - - handleMouseEnter = () => { - clearTimeout(this.closeTimeout); - this.showPopover(); - }; - - handleMouseLeave = () => { - this.closeTimeout = setTimeout(() => this.hidePopover(), 100); - }; - - handleClick = (event) => { - event.stopPropagation(); - this.openValue ? this.hidePopover() : this.showPopover(); - }; - - handleOutsideClick = (event) => { - if (this.element.contains(event.target)) return; - if (!this.openValue) return; - - this.hidePopover(); - }; - - handleKeydown = (event) => { - if (event.key !== "Escape") return; - if (!this.openValue) return; - - clearTimeout(this.closeTimeout); - this.hidePopover(); - }; - - // openValue is set here rather than by the callers, so a guarded early return can - // never leave the DOM claiming the popover is open while nothing is wired up. - showPopover() { - if (!this.hasTriggerTarget || !this.hasContentTarget) return; - - this.openValue = true; - this.contentTarget.classList.remove("hidden"); - this.contentTarget.dataset.state = "open"; - document.addEventListener("keydown", this.handleKeydown); - this.updatePosition(); - } - - // Same rule as disconnect(): release what is held outside the element first, so a - // missing content target cannot leave the keydown listener or autoUpdate running. - hidePopover() { - this.openValue = false; - document.removeEventListener("keydown", this.handleKeydown); - this.stopAutoUpdate(); - - if (!this.hasContentTarget) return; - - this.contentTarget.classList.add("hidden"); - this.contentTarget.dataset.state = "closed"; - } - - updatePosition() { - this.stopAutoUpdate(); - - // Hold the exact pair this run positions. A target can be detached or swapped - // while the controller stays connected, and the stale element must not be - // written to by an observer callback or an in-flight computePosition. - const trigger = this.triggerTarget; - const content = this.contentTarget; - - // Deferred teardown is bound to this run's own handle, so a newer positioning - // run installed before the microtask drains is never torn down by an older one. - let stop = null; - const releaseThisRun = () => { - stop?.(); - if (this.cleanup === stop) this.cleanup = null; - }; - - stop = autoUpdate(trigger, content, () => { - if (!trigger.isConnected || !content.isConnected) { - // Release the observers instead of throwing on every scroll and resize. - // Deferred because autoUpdate runs this once synchronously, before the - // handle below has been assigned. - queueMicrotask(releaseThisRun); - return; - } - - computePosition(trigger, content, { - placement: this.optionsValue.placement || "bottom", - middleware: [flip(), shift(), offset(8)], - }).then(({ x, y, placement }) => { - if (!content.isConnected) return; - - Object.assign(content.style, { - left: `${x}px`, - top: `${y}px`, - }); - // flip() can resolve to the opposite side of the requested placement, - // so the directional slide-in classes must follow the resolved value. - content.dataset.side = placement.split("-")[0]; - }); - }); - - this.cleanup = stop; - } - - stopAutoUpdate() { - if (!this.cleanup) return; - - this.cleanup(); - this.cleanup = null; - } -} diff --git a/docs/app/javascript/controllers/ruby_ui/popover_controller.js b/docs/app/javascript/controllers/ruby_ui/popover_controller.js new file mode 120000 index 000000000..3d7d57a2e --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/popover_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/popover/popover_controller.js \ No newline at end of file diff --git a/docs/app/javascript/controllers/ruby_ui/select_controller.js b/docs/app/javascript/controllers/ruby_ui/select_controller.js deleted file mode 100644 index 918b30670..000000000 --- a/docs/app/javascript/controllers/ruby_ui/select_controller.js +++ /dev/null @@ -1,171 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; -import { computePosition, autoUpdate, offset, flip } from "@floating-ui/dom"; - -export default class extends Controller { - static targets = ["trigger", "content", "input", "value", "item"]; - static values = { open: Boolean }; - static outlets = ["ruby-ui--select-item"]; - - constructor(...args) { - super(...args); - this.cleanup; - } - - connect() { - this.setFloatingElement(); - this.generateItemsIds(); - } - - disconnect() { - this.cleanup(); - } - - selectItem(event) { - event.preventDefault(); - - this.rubyUiSelectItemOutlets.forEach((item) => - item.handleSelectItem(event), - ); - - const oldValue = this.inputTarget.value; - const newValue = event.target.dataset.value; - - this.inputTarget.value = newValue; - this.valueTarget.innerText = event.target.innerText; - - this.dispatchOnChange(oldValue, newValue); - this.closeContent(); - } - - onClick() { - this.toogleContent(); - - if (this.openValue) { - this.setFocusAndCurrent(); - } else { - this.resetCurrent(); - } - } - - handleKeyDown(event) { - event.preventDefault(); - - const currentIndex = this.itemTargets.findIndex( - (item) => item.getAttribute("aria-current") === "true", - ); - - if (currentIndex + 1 < this.itemTargets.length) { - this.itemTargets[currentIndex].removeAttribute("aria-current"); - this.setAriaCurrentAndActiveDescendant(currentIndex + 1); - } - } - - handleKeyUp(event) { - event.preventDefault(); - - const currentIndex = this.itemTargets.findIndex( - (item) => item.getAttribute("aria-current") === "true", - ); - - if (currentIndex > 0) { - this.itemTargets[currentIndex].removeAttribute("aria-current"); - this.setAriaCurrentAndActiveDescendant(currentIndex - 1); - } - } - - handleEsc(event) { - event.preventDefault(); - this.closeContent(); - } - - setFocusAndCurrent() { - const selectedItem = this.itemTargets.find( - (item) => item.getAttribute("aria-selected") === "true", - ); - - if (selectedItem) { - selectedItem.focus({ preventScroll: true }); - selectedItem.setAttribute("aria-current", "true"); - this.triggerTarget.setAttribute( - "aria-activedescendant", - selectedItem.getAttribute("id"), - ); - } else { - this.itemTarget.focus({ preventScroll: true }); - this.itemTarget.setAttribute("aria-current", "true"); - this.triggerTarget.setAttribute( - "aria-activedescendant", - this.itemTarget.getAttribute("id"), - ); - } - } - - resetCurrent() { - this.itemTargets.forEach((item) => item.removeAttribute("aria-current")); - } - - clickOutside(event) { - if (!this.openValue) return; - if (this.element.contains(event.target)) return; - - event.preventDefault(); - this.toogleContent(); - } - - toogleContent() { - this.openValue = !this.openValue; - this.contentTarget.classList.toggle("hidden"); - this.triggerTarget.setAttribute("aria-expanded", this.openValue); - } - - setFloatingElement() { - this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => { - computePosition(this.triggerTarget, this.contentTarget, { - middleware: [offset(4), flip()], - }).then(({ x, y }) => { - Object.assign(this.contentTarget.style, { - left: `${x}px`, - top: `${y}px`, - }); - }); - }); - } - - generateItemsIds() { - const contentId = this.contentTarget.getAttribute("id"); - this.triggerTarget.setAttribute("aria-controls", contentId); - - this.itemTargets.forEach((item, index) => { - item.id = `${contentId}-${index}`; - }); - } - - setAriaCurrentAndActiveDescendant(currentIndex) { - const currentItem = this.itemTargets[currentIndex]; - currentItem.focus({ preventScroll: true }); - currentItem.setAttribute("aria-current", "true"); - this.triggerTarget.setAttribute( - "aria-activedescendant", - currentItem.getAttribute("id"), - ); - } - - closeContent() { - this.toogleContent(); - this.resetCurrent(); - - this.triggerTarget.setAttribute("aria-activedescendant", true); - this.triggerTarget.focus({ preventScroll: true }); - } - - dispatchOnChange(oldValue, newValue) { - if (oldValue === newValue) return; - - const event = new InputEvent("change", { - bubbles: true, - cancelable: true, - }); - - this.inputTarget.dispatchEvent(event); - } -} diff --git a/docs/app/javascript/controllers/ruby_ui/select_controller.js b/docs/app/javascript/controllers/ruby_ui/select_controller.js new file mode 120000 index 000000000..260caeb3e --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/select_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/select/select_controller.js \ No newline at end of file diff --git a/docs/app/javascript/controllers/ruby_ui/select_item_controller.js b/docs/app/javascript/controllers/ruby_ui/select_item_controller.js deleted file mode 100644 index 3d76417d7..000000000 --- a/docs/app/javascript/controllers/ruby_ui/select_item_controller.js +++ /dev/null @@ -1,11 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; -export default class extends Controller { - - handleSelectItem({ target }) { - if (this.element.dataset.value == target.dataset.value) { - this.element.setAttribute("aria-selected", true); - } else { - this.element.removeAttribute("aria-selected"); - } - } -} diff --git a/docs/app/javascript/controllers/ruby_ui/select_item_controller.js b/docs/app/javascript/controllers/ruby_ui/select_item_controller.js new file mode 120000 index 000000000..fe830cece --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/select_item_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/select/select_item_controller.js \ No newline at end of file diff --git a/docs/app/javascript/controllers/ruby_ui/sheet_content_controller.js b/docs/app/javascript/controllers/ruby_ui/sheet_content_controller.js deleted file mode 100644 index 8df0712be..000000000 --- a/docs/app/javascript/controllers/ruby_ui/sheet_content_controller.js +++ /dev/null @@ -1,7 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -export default class extends Controller { - close() { - this.element.remove() - } -} diff --git a/docs/app/javascript/controllers/ruby_ui/sheet_content_controller.js b/docs/app/javascript/controllers/ruby_ui/sheet_content_controller.js new file mode 120000 index 000000000..8ad778cfc --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/sheet_content_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/sheet/sheet_content_controller.js \ No newline at end of file diff --git a/docs/app/javascript/controllers/ruby_ui/sheet_controller.js b/docs/app/javascript/controllers/ruby_ui/sheet_controller.js deleted file mode 100644 index 45e98637a..000000000 --- a/docs/app/javascript/controllers/ruby_ui/sheet_controller.js +++ /dev/null @@ -1,9 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -export default class extends Controller { - static targets = ["content"] - - open() { - document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML) - } -} diff --git a/docs/app/javascript/controllers/ruby_ui/sheet_controller.js b/docs/app/javascript/controllers/ruby_ui/sheet_controller.js new file mode 120000 index 000000000..f74032d48 --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/sheet_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/sheet/sheet_controller.js \ No newline at end of file diff --git a/docs/app/javascript/controllers/ruby_ui/sidebar_controller.js b/docs/app/javascript/controllers/ruby_ui/sidebar_controller.js deleted file mode 100644 index c789438d9..000000000 --- a/docs/app/javascript/controllers/ruby_ui/sidebar_controller.js +++ /dev/null @@ -1,67 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; - -const SIDEBAR_COOKIE_NAME = "sidebar_state"; -const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; -const State = { - EXPANDED: "expanded", - COLLAPSED: "collapsed", -}; -const MOBILE_BREAKPOINT = 768; - -export default class extends Controller { - static targets = ["sidebar", "mobileSidebar"]; - - sidebarTargetConnected() { - const { state, collapsibleKind } = this.sidebarTarget.dataset; - - this.open = state === State.EXPANDED; - this.collapsibleKind = collapsibleKind; - } - - toggle(e) { - e.preventDefault(); - - if (this.#isMobile()) { - this.#openMobileSidebar(); - - return; - } - - this.open = !this.open; - this.onToggle(); - } - - onToggle() { - this.#updateSidebarState(); - this.#persistSidebarState(); - } - - #updateSidebarState() { - if (!this.hasSidebarTarget) { - return; - } - - const { dataset } = this.sidebarTarget; - - dataset.state = this.open ? State.EXPANDED : State.COLLAPSED; - dataset.collapsible = this.open ? "" : this.collapsibleKind; - } - - #persistSidebarState() { - document.cookie = `${SIDEBAR_COOKIE_NAME}=${this.open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; - } - - #isMobile() { - return window.innerWidth < MOBILE_BREAKPOINT; - } - - #openMobileSidebar() { - if (!this.hasMobileSidebarTarget) { - return; - } - - this.mobileSidebarTarget.dispatchEvent( - new CustomEvent("ruby--ui-sidebar:open"), - ); - } -} diff --git a/docs/app/javascript/controllers/ruby_ui/sidebar_controller.js b/docs/app/javascript/controllers/ruby_ui/sidebar_controller.js new file mode 120000 index 000000000..165318c18 --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/sidebar_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/sidebar/sidebar_controller.js \ No newline at end of file diff --git a/docs/app/javascript/controllers/ruby_ui/tabs_controller.js b/docs/app/javascript/controllers/ruby_ui/tabs_controller.js deleted file mode 100644 index e46d69c46..000000000 --- a/docs/app/javascript/controllers/ruby_ui/tabs_controller.js +++ /dev/null @@ -1,45 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; - -// Connects to data-controller="ruby-ui--tabs" -export default class extends Controller { - static targets = ["trigger", "content"]; - static values = { active: String }; - - connect() { - if (!this.hasActiveValue && this.triggerTargets.length > 0) { - this.activeValue = this.triggerTargets[0].dataset.value; - } - } - - show(e) { - this.activeValue = e.currentTarget.dataset.value; - } - - activeValueChanged(currentValue, previousValue) { - if (currentValue == "" || currentValue == previousValue) return; - - this.contentTargets.forEach((el) => { - el.classList.add("hidden"); - }); - - this.triggerTargets.forEach((el) => { - el.dataset.state = "inactive"; - }); - - this.activeContentTarget() && - this.activeContentTarget().classList.remove("hidden"); - this.activeTriggerTarget().dataset.state = "active"; - } - - activeTriggerTarget() { - return this.triggerTargets.find( - (el) => el.dataset.value == this.activeValue, - ); - } - - activeContentTarget() { - return this.contentTargets.find( - (el) => el.dataset.value == this.activeValue, - ); - } -} diff --git a/docs/app/javascript/controllers/ruby_ui/tabs_controller.js b/docs/app/javascript/controllers/ruby_ui/tabs_controller.js new file mode 120000 index 000000000..f849759bd --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/tabs_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/tabs/tabs_controller.js \ No newline at end of file diff --git a/docs/app/javascript/controllers/ruby_ui/theme_toggle_controller.js b/docs/app/javascript/controllers/ruby_ui/theme_toggle_controller.js deleted file mode 100644 index b1eb97f78..000000000 --- a/docs/app/javascript/controllers/ruby_ui/theme_toggle_controller.js +++ /dev/null @@ -1,38 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -// Connects to data-controller="ruby-ui--theme-toggle" -// Sits on the same wrapper as ruby-ui--toggle. Listens for the toggle's -// ruby-ui--toggle:change event. pressed = dark mode. -export default class extends Controller { - connect() { - this.applyTheme(this.currentTheme()) - } - - apply(event) { - const pressed = event.detail?.pressed - const theme = pressed ? "dark" : "light" - localStorage.theme = theme - this.applyTheme(theme) - } - - currentTheme() { - if (localStorage.theme === "dark") return "dark" - if (localStorage.theme === "light") return "light" - return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light" - } - - applyTheme(theme) { - const html = document.documentElement - if (theme === "dark") { - html.classList.add("dark") - html.classList.remove("light") - } else { - html.classList.add("light") - html.classList.remove("dark") - } - // Flip the sibling Toggle controller's pressed value; it will propagate - // aria-pressed / data-state to the button target. - const dark = theme === "dark" - this.element.setAttribute("data-ruby-ui--toggle-pressed-value", dark ? "true" : "false") - } -} diff --git a/docs/app/javascript/controllers/ruby_ui/theme_toggle_controller.js b/docs/app/javascript/controllers/ruby_ui/theme_toggle_controller.js new file mode 120000 index 000000000..bcc4212b2 --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/theme_toggle_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/theme_toggle/theme_toggle_controller.js \ No newline at end of file diff --git a/docs/app/javascript/controllers/ruby_ui/toast_controller.js b/docs/app/javascript/controllers/ruby_ui/toast_controller.js deleted file mode 100644 index 99010a8fe..000000000 --- a/docs/app/javascript/controllers/ruby_ui/toast_controller.js +++ /dev/null @@ -1,151 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -const SWIPE_THRESHOLD = 45 -const TIME_BEFORE_UNMOUNT = 200 - -// Connects to data-controller="ruby-ui--toast" -export default class extends Controller { - static values = { - duration: { type: Number, default: 4000 }, - dismissible: { type: Boolean, default: true }, - invert: { type: Boolean, default: false }, - onDismiss: String, - onAutoClose: String, - } - - connect() { - this._timer = null - this._startedAt = 0 - this._remaining = this.durationValue - this._paused = false - this._swipe = { active: false, x: 0, y: 0, startedAt: 0 } - - this._onPointerDown = this._onPointerDown.bind(this) - this._onPointerMove = this._onPointerMove.bind(this) - this._onPointerUp = this._onPointerUp.bind(this) - this._onPointerEnter = () => this._pause() - this._onPointerLeave = () => { if (!this._swipe.active) this._resume() } - this._onKeyDown = this._onKeyDown.bind(this) - this._onForceDismiss = (e) => { e.stopPropagation(); this._close() } - this._onRestart = () => this._restart() - this._onRegionPause = () => this._pause() - this._onRegionResume = () => this._resume() - - this.element.addEventListener("pointerdown", this._onPointerDown) - this.element.addEventListener("pointerenter", this._onPointerEnter) - this.element.addEventListener("pointerleave", this._onPointerLeave) - this.element.addEventListener("keydown", this._onKeyDown) - this.element.addEventListener("ruby-ui:toast:force-dismiss", this._onForceDismiss) - this.element.addEventListener("ruby-ui:toast:restart", this._onRestart) - document.addEventListener("ruby-ui:toast:pause", this._onRegionPause) - document.addEventListener("ruby-ui:toast:resume", this._onRegionResume) - - requestAnimationFrame(() => { - this.element.dataset.state = "open" - this._start() - }) - } - - disconnect() { - this._clearTimer() - this.element.removeEventListener("pointerdown", this._onPointerDown) - this.element.removeEventListener("pointerenter", this._onPointerEnter) - this.element.removeEventListener("pointerleave", this._onPointerLeave) - this.element.removeEventListener("keydown", this._onKeyDown) - this.element.removeEventListener("ruby-ui:toast:force-dismiss", this._onForceDismiss) - this.element.removeEventListener("ruby-ui:toast:restart", this._onRestart) - document.removeEventListener("ruby-ui:toast:pause", this._onRegionPause) - document.removeEventListener("ruby-ui:toast:resume", this._onRegionResume) - } - - dismiss(e) { - e?.preventDefault() - if (!this.dismissibleValue) return - this._close("dismiss") - } - - _close(reason) { - if (this.element.dataset.state === "closing") return - this.element.dataset.state = "closing" - this.element.dispatchEvent(new CustomEvent(reason === "auto" ? "ruby-ui:toast:auto-close" : "ruby-ui:toast:dismiss", { bubbles: true, detail: { id: this.element.id } })) - setTimeout(() => this.element.remove(), TIME_BEFORE_UNMOUNT) - } - - _start() { - if (!Number.isFinite(this.durationValue) || this.durationValue <= 0) return - this._startedAt = performance.now() - this._remaining = this.durationValue - this._timer = setTimeout(() => this._close("auto"), this._remaining) - } - - _restart() { - this._clearTimer() - this._start() - } - - _pause() { - if (this._paused || !this._timer) return - this._paused = true - clearTimeout(this._timer) - this._timer = null - this._remaining -= performance.now() - this._startedAt - } - - _resume() { - if (!this._paused) return - this._paused = false - if (this._remaining <= 0) return this._close("auto") - this._startedAt = performance.now() - this._timer = setTimeout(() => this._close("auto"), this._remaining) - } - - _clearTimer() { - if (this._timer) clearTimeout(this._timer) - this._timer = null - } - - _onKeyDown(e) { - if (e.key === "Escape" && this.dismissibleValue) this.dismiss(e) - } - - _onPointerDown(e) { - if (!this.dismissibleValue) return - if (e.target.closest("button")) return - try { this.element.setPointerCapture(e.pointerId) } catch {} - this._swipe = { active: true, x: e.clientX, y: e.clientY, startedAt: performance.now(), pointerId: e.pointerId } - this.element.dataset.swipe = "start" - this.element.addEventListener("pointermove", this._onPointerMove) - this.element.addEventListener("pointerup", this._onPointerUp) - this.element.addEventListener("pointercancel", this._onPointerUp) - } - - _onPointerMove(e) { - const dx = e.clientX - this._swipe.x - const dy = e.clientY - this._swipe.y - this.element.dataset.swipe = "move" - this.element.style.transform = `translate(${dx}px, ${dy}px)` - } - - _onPointerUp(e) { - const dx = e.clientX - this._swipe.x - const dy = e.clientY - this._swipe.y - const dist = Math.hypot(dx, dy) - const dt = performance.now() - this._swipe.startedAt - const velocity = dist / Math.max(dt, 1) - this.element.removeEventListener("pointermove", this._onPointerMove) - this.element.removeEventListener("pointerup", this._onPointerUp) - this.element.removeEventListener("pointercancel", this._onPointerUp) - this._swipe.active = false - if (dist > SWIPE_THRESHOLD || velocity > 0.5) { - this.element.style.setProperty("--swipe-end-x", `${Math.sign(dx) * 500}px`) - this.element.style.setProperty("--swipe-end-y", `${Math.sign(dy) * 500}px`) - this.element.dataset.swipe = "end" - this.element.style.transform = "" - this._close("dismiss") - } else { - this.element.dataset.swipe = "cancel" - this.element.style.transform = "" - this._resume() - } - } -} diff --git a/docs/app/javascript/controllers/ruby_ui/toast_controller.js b/docs/app/javascript/controllers/ruby_ui/toast_controller.js new file mode 120000 index 000000000..215dd184c --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/toast_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/toast/toast_controller.js \ No newline at end of file diff --git a/docs/app/javascript/controllers/ruby_ui/toaster_controller.js b/docs/app/javascript/controllers/ruby_ui/toaster_controller.js deleted file mode 100644 index b3976c5bd..000000000 --- a/docs/app/javascript/controllers/ruby_ui/toaster_controller.js +++ /dev/null @@ -1,306 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -const VARIANTS = ["default", "success", "error", "warning", "info", "loading"] - -let streamActionRegistered = false - -function registerStreamAction() { - if (streamActionRegistered) return - if (typeof window === "undefined") return - const Turbo = window.Turbo - if (!Turbo?.StreamActions) return - Turbo.StreamActions.toast = function () { - const detail = {} - for (const attr of this.attributes) { - if (attr.name === "action" || attr.name === "target" || attr.name === "targets") continue - detail[attr.name] = attr.value - } - if (detail.duration != null && detail.duration !== "") detail.duration = Number(detail.duration) - if (detail.dismissible != null) detail.dismissible = detail.dismissible !== "false" - window.dispatchEvent(new CustomEvent("ruby-ui:toast", { detail })) - } - streamActionRegistered = true -} - -// Connects to data-controller="ruby-ui--toaster" -export default class extends Controller { - static targets = ["skeleton", "toast", "actionTpl", "cancelTpl", "closeTpl"] - static values = { - position: { type: String, default: "bottom-right" }, - expand: { type: Boolean, default: false }, - max: { type: Number, default: 3 }, - duration: { type: Number, default: 4000 }, - gap: { type: Number, default: 14 }, - offset: { type: Number, default: 24 }, - theme: { type: String, default: "system" }, - richColors: { type: Boolean, default: false }, - closeButton: { type: Boolean, default: false }, - hotkey: { type: String, default: "alt+t" }, - dir: { type: String, default: "ltr" }, - } - - connect() { - this._heights = new Map() - this._resizeObservers = new WeakMap() - this._expanded = this.expandValue - this._listEl = this.element.querySelector("ol") || (this.element.tagName === "OL" ? this.element : null) - this._registerGlobalApi() - registerStreamAction() - if (!this._listEl) return - - this._onPointerEnter = () => this._setExpanded(true) - this._onPointerLeave = () => { if (!this.expandValue) this._setExpanded(false) } - this._onWindowToast = (e) => this._spawn(e.detail || {}) - this._onWindowDismissAll = () => this._dismissById(null) - this._onKey = this._onKey.bind(this) - - window.addEventListener("ruby-ui:toast", this._onWindowToast) - window.addEventListener("ruby-ui:toast:dismiss-all", this._onWindowDismissAll) - this._listEl.addEventListener("pointerenter", this._onPointerEnter) - this._listEl.addEventListener("pointerleave", this._onPointerLeave) - document.addEventListener("keydown", this._onKey) - } - - disconnect() { - window.removeEventListener("ruby-ui:toast", this._onWindowToast) - window.removeEventListener("ruby-ui:toast:dismiss-all", this._onWindowDismissAll) - this._listEl?.removeEventListener("pointerenter", this._onPointerEnter) - this._listEl?.removeEventListener("pointerleave", this._onPointerLeave) - document.removeEventListener("keydown", this._onKey) - } - - toastTargetConnected(el) { - if (typeof ResizeObserver !== "undefined") { - const ro = new ResizeObserver(() => { - this._heights.set(el, el.offsetHeight) - this._reflow() - }) - ro.observe(el) - this._resizeObservers.set(el, ro) - } - this._heights.set(el, el.offsetHeight || 64) - this._reflow() - } - - toastTargetDisconnected(el) { - this._resizeObservers.get(el)?.disconnect() - this._resizeObservers.delete(el) - this._heights.delete(el) - this._reflow() - } - - _spawn(detail) { - const variant = VARIANTS.includes(detail.variant) ? detail.variant : "default" - const tpl = this._skeletonFor(variant) - if (!tpl) return null - if (detail.position) { - this.element.setAttribute("data-position", detail.position) - this.positionValue = detail.position - } - const node = tpl.content.firstElementChild.cloneNode(true) - - node.id = detail.id || `toast-${this._uuid()}` - if (detail.duration != null) { - const dur = detail.duration === Infinity ? 0 : detail.duration - node.setAttribute("data-ruby-ui--toast-duration-value", String(dur)) - } - if (detail.dismissible === false) { - node.setAttribute("data-ruby-ui--toast-dismissible-value", "false") - } - if (detail.className) node.className += ` ${detail.className}` - - const titleEl = node.querySelector('[data-slot="title"]') - if (titleEl) titleEl.textContent = detail.title || detail.message || "" - const descEl = node.querySelector('[data-slot="description"]') - if (descEl) { - if (detail.description) descEl.textContent = detail.description - else descEl.remove() - } - - if (detail.action && detail.action.label && this.hasActionTplTarget) { - const btn = this._cloneSlot(this.actionTplTarget) - btn.textContent = detail.action.label - btn.addEventListener("click", (ev) => { - try { detail.action.onClick?.(ev) } finally { - node.dispatchEvent(new CustomEvent("ruby-ui:toast:force-dismiss", { bubbles: true })) - } - }) - node.appendChild(btn) - } - - if (detail.cancel && detail.cancel.label && this.hasCancelTplTarget) { - const btn = this._cloneSlot(this.cancelTplTarget) - btn.textContent = detail.cancel.label - node.appendChild(btn) - } - - if (detail.closeButton && this.hasCloseTplTarget) { - const x = this._cloneSlot(this.closeTplTarget) - node.classList.add("pr-10") - node.appendChild(x) - } - - this._listEl.appendChild(node) - return node.id - } - - _dismissById(id) { - if (!id) { - this.toastTargets.forEach((el) => - el.dispatchEvent(new CustomEvent("ruby-ui:toast:force-dismiss", { bubbles: true })) - ) - return - } - const el = this._listEl.querySelector(`#${CSS.escape(id)}`) - if (el) el.dispatchEvent(new CustomEvent("ruby-ui:toast:force-dismiss", { bubbles: true })) - } - - _skeletonFor(variant) { - return this.skeletonTargets.find((t) => t.dataset.variant === variant) - } - - _cloneSlot(tpl) { - return tpl.content.firstElementChild.cloneNode(true) - } - - _setExpanded(value) { - if (this._expanded === value) return - this._expanded = value - document.dispatchEvent(new CustomEvent(value ? "ruby-ui:toast:pause" : "ruby-ui:toast:resume")) - this._reflow() - } - - _reflow() { - if (!this._listEl) return - const isBottom = this.positionValue.startsWith("bottom") - const items = this.toastTargets - const order = isBottom ? items.slice().reverse() : items.slice() - const heights = order.map(el => this._heights.get(el) || el.offsetHeight || 64) - const gap = this.gapValue - const peekOffset = 16 - const peekScaleStep = 0.05 - const peekOpacityStep = 0.2 - - const expandedHeight = heights.reduce((a, b) => a + b, 0) + gap * Math.max(0, heights.length - 1) - const collapsedHeight = (heights[0] || 0) + Math.min(2, Math.max(0, heights.length - 1)) * peekOffset - this._listEl.style.minHeight = `${this._expanded ? expandedHeight : collapsedHeight}px` - - let acc = 0 - order.forEach((el, i) => { - const visible = i < this.maxValue - let yOffset, scale, opacity - - if (this._expanded) { - yOffset = acc + i * gap - scale = 1 - opacity = visible ? 1 : 0 - } else { - yOffset = i * peekOffset - scale = Math.max(0.85, 1 - i * peekScaleStep) - opacity = visible ? Math.max(0, 1 - i * peekOpacityStep) : 0 - } - - const sign = isBottom ? -1 : 1 - const ty = sign * yOffset - - el.style.setProperty("--opacity", String(opacity)) - el.style.setProperty("--scale", String(scale)) - el.style.setProperty("--y-offset", `${ty}px`) - el.style.transformOrigin = isBottom ? "center bottom" : "center top" - el.style.top = isBottom ? "auto" : "0" - el.style.bottom = isBottom ? "0" : "auto" - el.style.transform = `translate3d(0, ${ty}px, 0) scale(${scale})` - el.style.zIndex = String(1000 - i) - el.style.pointerEvents = visible ? "auto" : "none" - el.tabIndex = visible ? 0 : -1 - - acc += heights[i] || 0 - }) - - this._enforceMax(items) - } - - _enforceMax(items) { - if (items.length <= this.maxValue) return - const isBottom = this.positionValue.startsWith("bottom") - const dropping = items.length - this.maxValue - const candidates = isBottom ? items.slice(0, dropping) : items.slice(-dropping) - candidates.forEach(el => { - if (el.dataset.state !== "closing") { - el.dispatchEvent(new CustomEvent("ruby-ui:toast:force-dismiss", { bubbles: true })) - } - }) - } - - _onKey(e) { - const parts = (this.hotkeyValue || "alt+t").split("+") - const key = parts.pop() - const wantAlt = parts.includes("alt") - const wantCtrl = parts.includes("ctrl") - const wantMeta = parts.includes("meta") - if (e.key.toLowerCase() !== key.toLowerCase()) return - if (wantAlt !== e.altKey) return - if (wantCtrl !== e.ctrlKey) return - if (wantMeta !== e.metaKey) return - e.preventDefault() - const first = this._listEl.firstElementChild - first?.focus() - } - - _registerGlobalApi() { - const fire = (variant, message, opts = {}) => - window.dispatchEvent(new CustomEvent("ruby-ui:toast", { - detail: { ...opts, variant, message: opts.title || message } - })) - - const api = (message, opts) => fire("default", message, opts) - api.success = (m, o) => fire("success", m, o) - api.error = (m, o) => fire("error", m, o) - api.warning = (m, o) => fire("warning", m, o) - api.info = (m, o) => fire("info", m, o) - api.loading = (m, o = {}) => fire("loading", m, { ...o, duration: o.duration ?? 0 }) - api.dismiss = (id) => { - if (id) this._dismissById(id) - else window.dispatchEvent(new CustomEvent("ruby-ui:toast:dismiss-all")) - } - api.promise = (p, msgs = {}) => { - const id = `toast-${this._uuid()}` - fire("loading", typeof msgs.loading === "function" ? msgs.loading() : (msgs.loading || "Loading..."), { id, duration: 0 }) - Promise.resolve(p).then( - (val) => this._mutate(id, "success", typeof msgs.success === "function" ? msgs.success(val) : msgs.success), - (err) => this._mutate(id, "error", typeof msgs.error === "function" ? msgs.error(err) : msgs.error) - ) - return id - } - - window.RubyUI = window.RubyUI || {} - window.RubyUI.toast = api - } - - _mutate(id, variant, text) { - const el = this._listEl.querySelector(`#${CSS.escape(id)}`) - if (!el) return - el.dataset.variant = variant - el.setAttribute("role", variant === "error" ? "alert" : "status") - this._swapIcon(el, variant) - const t = el.querySelector('[data-slot="title"]') - if (t && text) t.textContent = text - const dur = String(this.durationValue) - el.setAttribute("data-ruby-ui--toast-duration-value", dur) - el.dispatchEvent(new CustomEvent("ruby-ui:toast:restart", { bubbles: true })) - } - - _swapIcon(el, variant) { - const iconHost = el.querySelector('[data-slot="icon"]') - if (!iconHost) return - const tpl = this._skeletonFor(variant) - if (!tpl) return - const sourceIcon = tpl.content.firstElementChild?.querySelector('[data-slot="icon"]') - iconHost.innerHTML = sourceIcon ? sourceIcon.innerHTML : "" - } - - _uuid() { - if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID() - return Math.random().toString(36).slice(2) + Date.now().toString(36) - } -} diff --git a/docs/app/javascript/controllers/ruby_ui/toaster_controller.js b/docs/app/javascript/controllers/ruby_ui/toaster_controller.js new file mode 120000 index 000000000..daae09712 --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/toaster_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/toast/toaster_controller.js \ No newline at end of file diff --git a/docs/app/javascript/controllers/ruby_ui/toggle_controller.js b/docs/app/javascript/controllers/ruby_ui/toggle_controller.js deleted file mode 100644 index 259488760..000000000 --- a/docs/app/javascript/controllers/ruby_ui/toggle_controller.js +++ /dev/null @@ -1,33 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -// Connects to data-controller="ruby-ui--toggle" -// Sits on a wrapper element; the visible