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