From 4ca053409373ae66ffa2ffed64110d0c84283d5b 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] [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. Supersedes still-open PR #490 for accordion_controller.js specifically: that PR's docs-side fix is now moot since the file is a symlink to the (already correct) gem source. Leaving #490's fate to a human reviewer. --- .claude/skills/ruby-ui-stimulus/SKILL.md | 4 + CLAUDE.md | 1 + docs/CLAUDE.md | 13 + .../ruby_ui/accordion_controller.js | 98 +---- .../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 | 108 +----- .../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(+), 3465 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 2408ce7fe..000000000 --- a/docs/app/javascript/controllers/ruby_ui/accordion_controller.js +++ /dev/null @@ -1,97 +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 contentHeight = this.contentTarget.scrollHeight; - animate( - this.contentTarget, - { height: `${contentHeight}px` }, - { - duration: this.animationDurationValue, - easing: this.animationEasingValue, - }, - ); - } - - // Hide the accordion content with animation - hideContent() { - animate( - this.contentTarget, - { height: 0 }, - { - duration: this.animationDurationValue, - easing: this.animationEasingValue, - }, - ); - } - - // 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 9b847764d..000000000 --- a/docs/app/javascript/controllers/ruby_ui/popover_controller.js +++ /dev/null @@ -1,107 +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(); - } - - disconnect() { - this.removeEventListeners(); - if (this.cleanup) { - this.cleanup(); - } - } - - 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); - } - } - - removeEventListeners() { - this.triggerTarget.removeEventListener("mouseenter", this.handleMouseEnter); - this.triggerTarget.removeEventListener("mouseleave", this.handleMouseLeave); - this.contentTarget.removeEventListener("mouseenter", this.handleMouseEnter); - this.contentTarget.removeEventListener("mouseleave", this.handleMouseLeave); - this.triggerTarget.removeEventListener("click", this.handleClick); - document.removeEventListener("click", this.handleOutsideClick); - } - - handleMouseEnter = () => { - clearTimeout(this.closeTimeout); - this.openValue = true; - this.showPopover(); - }; - - handleMouseLeave = () => { - this.closeTimeout = setTimeout(() => { - this.openValue = false; - this.hidePopover(); - }, 100); - }; - - handleClick = (event) => { - event.stopPropagation(); - this.openValue = !this.openValue; - this.openValue ? this.showPopover() : this.hidePopover(); - }; - - handleOutsideClick = (event) => { - if (!this.element.contains(event.target) && this.openValue) { - this.openValue = false; - this.hidePopover(); - } - }; - - showPopover() { - this.contentTarget.classList.remove("hidden"); - this.updatePosition(); - } - - hidePopover() { - this.contentTarget.classList.add("hidden"); - if (this.cleanup) { - this.cleanup(); - } - } - - updatePosition() { - if (this.cleanup) { - this.cleanup(); - } - - this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => { - computePosition(this.triggerTarget, this.contentTarget, { - placement: this.optionsValue.placement || "bottom", - middleware: [flip(), shift(), offset(8)], - }).then(({ x, y }) => { - Object.assign(this.contentTarget.style, { - left: `${x}px`, - top: `${y}px`, - }); - }); - }); - } -} 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