Merge branch 'main' into latest-activity

* main:
  Don't round up in time distance expression
  Tidy Gemfile
  Banner for draft state
  Drafts should always use the dashed outline wherever they're displayed
  Unnecessary parens
  Use distance to nearest edge
  Simplify overlap logic
  Fix divider drag jankiness
  Use cache-friendly "time ago"
  positionedClass -> installedClass
  Pull out moveDividerTo
  Before comes before After
  DividerElement -> Divider
  Clean up divider-controller
  Drag bubble divider
This commit is contained in:
Jason Zimdars
2025-01-21 13:50:28 -06:00
17 changed files with 243 additions and 56 deletions
@@ -0,0 +1,83 @@
import { Controller } from "@hotwired/stimulus"
const MOVE_ITEM_DATA_TYPE = "x-fizzy/move"
const DIVIDER_ITEM_NODE_NAME = "LI"
const OVERLAP_THRESHOLD = 0.25
export default class extends Controller {
static targets = [ "divider", "dragImage", "count" ]
static classes = [ "installed", "dragging" ]
static values = { startCount: Number, maxCount: Number }
connect() {
this.install()
}
install() {
this.#moveDividerTo(this.startCountValue)
this.dividerTarget.classList.add(this.installedClass)
}
configureDrag(event) {
if (event.target == this.dividerTarget) {
event.dataTransfer.dropEffect = "move"
event.dataTransfer.setData(MOVE_ITEM_DATA_TYPE, event.target)
event.dataTransfer.setDragImage(this.dragImageTarget, 0, 0)
this.element.classList.add(this.draggingClass)
}
}
acceptDrop(event) {
if (event.dataTransfer.types.includes(MOVE_ITEM_DATA_TYPE)) {
event.preventDefault()
}
}
moveDivider(event) {
if (event.target.nodeName == DIVIDER_ITEM_NODE_NAME) {
const rect = this.dividerTarget.getBoundingClientRect()
const distanceToTop = Math.abs(event.clientY - rect.top)
const distanceToBottom = Math.abs(event.clientY - (rect.top + rect.height))
const distanceToNearestEdge = Math.min(distanceToTop, distanceToBottom)
const overlap = distanceToNearestEdge / rect.height
if (overlap > OVERLAP_THRESHOLD) {
this.#moveDividerTo(this.#items.indexOf(event.target))
}
}
}
drop() {
this.element.classList.remove(this.draggingClass)
}
#moveDividerTo(index) {
if (index <= this.maxCountValue) {
if (this.#dividerIndex < index) {
this.#positionDividerAfter(index)
} else if (this.#dividerIndex > index) {
this.#positionDividerBefore(index)
}
}
}
#positionDividerBefore(index) {
const position = Math.max(index, 1)
this.#items[position].before(this.dividerTarget)
this.countTarget.textContent = position
}
#positionDividerAfter(index) {
const position = Math.min(index, this.#items.length - 1, this.maxCountValue)
this.#items[position].after(this.dividerTarget)
this.countTarget.textContent = position
}
get #items() {
return Array.from(this.element.children)
}
get #dividerIndex() {
return this.#items.indexOf(this.dividerTarget)
}
}
@@ -0,0 +1,62 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["time", "date", "datetime", "ago"]
initialize() {
this.timeFormatter = new Intl.DateTimeFormat(undefined, { timeStyle: "short" })
this.dateFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "long" })
this.dateTimeFormatter = new Intl.DateTimeFormat(undefined, { timeStyle: "short", dateStyle: "short" })
this.agoFormatter = new AgoFormatter()
}
timeTargetConnected(target) {
this.#formatTime(this.timeFormatter, target)
}
dateTargetConnected(target) {
this.#formatTime(this.dateFormatter, target)
}
datetimeTargetConnected(target) {
this.#formatTime(this.dateTimeFormatter, target)
}
agoTargetConnected(target) {
this.#formatTime(this.agoFormatter, target)
}
#formatTime(formatter, target) {
const dt = new Date(target.getAttribute("datetime"))
target.textContent = formatter.format(dt)
target.title = this.dateTimeFormatter.format(dt)
}
}
class AgoFormatter {
format(dt) {
const now = new Date()
const seconds = (now - dt) / 1000
const minutes = seconds / 60
const hours = minutes / 60
const days = hours / 24
const weeks = days / 7
const months = days / (365 / 12)
const years = days / 365
if (years >= 1) return this.#pluralize("year", years)
if (months >= 1) return this.#pluralize("month", months)
if (weeks >= 1) return this.#pluralize("week", weeks)
if (days >= 1) return this.#pluralize("day", days)
if (hours >= 1) return this.#pluralize("hour", hours)
if (minutes >= 1) return this.#pluralize("minute", minutes)
return "Less than a minute ago"
}
#pluralize(word, quantity) {
quantity = Math.floor(quantity)
const suffix = (quantity === 1) ? "" : "s"
return `${quantity} ${word}${suffix} ago`
}
}
@@ -1,10 +0,0 @@
import { Controller } from "@hotwired/stimulus"
// FIXME: Can we do this without a controller? https://github.com/basecamp/fizzy/pull/130#discussion_r1833094616
export default class extends Controller {
merge({ params: { key, value } }) {
const url = new URL(window.location.href)
url.searchParams.set(key, value)
Turbo.visit(url)
}
}