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
+1 -2
View File
@@ -16,6 +16,7 @@ gem "bootsnap", require: false
gem "puma", ">= 5.0"
gem "solid_cable", ">= 3.0"
gem "solid_cache", "~> 1.0"
gem "solid_queue", "~> 1.1"
gem "sqlite3", ">= 2.0"
gem "thruster", require: false
@@ -44,5 +45,3 @@ group :test do
gem "capybara"
gem "selenium-webdriver"
end
gem "solid_queue", "~> 1.1"
+51 -18
View File
@@ -366,18 +366,24 @@
text-decoration: none;
}
li {
border-radius: 0.6em;
list-style: none;
padding: 0.5em var(--inline-space);
position: relative;
transition: background-color 200ms ease-out;
ul {
li {
border-radius: 0.6em;
list-style: none;
padding: 0.5em var(--inline-space);
position: relative;
transition: background-color 200ms ease-out;
}
@media (hover: hover) {
&:hover {
background-color: color(from var(--bubble-color) srgb r g b / 0.15);
border: 0;
border-radius: 0.6em;
&:not(.dragging) {
li {
@media (hover: hover) {
&:hover {
background-color: color(from var(--bubble-color) srgb r g b / 0.15);
border: 0;
border-radius: 0.6em;
}
}
}
}
}
@@ -397,13 +403,29 @@
--divider-color: var(--color-subtle-dark);
font-size: 1rem;
cursor: grab;
visibility: hidden;
}
.bubbles-list__divider--installed {
visibility: visible;
}
.divider-drag-image {
background-color: var(--color-link);
border-radius: 1rem;
color: var(--color-ink-reversed);
inset: auto 100% 100% auto;
line-height: 2rem;
padding: 0 1rem;
position: fixed;
text-wrap: nowrap;
}
.btn--reversed.bubbles-list__handle {
--btn-background: var(--divider-color);
font-size: 0.9rem;
cursor: grab;
}
.bubbles-list__count {
@@ -417,6 +439,10 @@
flex-grow: 1;
white-space: nowrap;
.drafted & {
color: color(from var(--color-ink) srgb r g b / 0.5);
}
&::after {
border-block-end: 1px dotted var(--color-subtle-dark);
content: "";
@@ -426,9 +452,12 @@
}
.bubble__shape {
background-color: var(--bubble-color);
background-color: var(--bubble-background-color, var(--bubble-color));
block-size: var(--bubble-size, 1.2em);
border-color: var(--bubble-border-color, var(--bubble-color));
border-radius: var(--bubble-shape);
border-style: var(--bubble-border-style, solid);
border-width: var(--bubble-border-width, 0);
inline-size: var(--bubble-size, 1.2em);
pointer-events: none;
position: relative;
@@ -438,13 +467,17 @@
.bubble & {
--bubble-size: 100%;
background-color: color(from var(--bubble-color) srgb r g b / 0.15);
border: var(--bubble-border-width) solid var(--bubble-color);
--bubble-background-color: color(from var(--bubble-color) srgb r g b / 0.15);
}
.bubble.drafted & {
border-style: dashed;
.drafted & {
--bubble-border-style: dashed;
.bubbles-list & {
--bubble-background-color: color(from var(--bubble-color) srgb r g b / 0.15);
--bubble-border-color: var(--bubble-color);
--bubble-border-width: 0.15rem;
}
}
.bubble:has(.bubble__link) & {
+4 -2
View File
@@ -168,8 +168,10 @@
.translucent { opacity: var(--opacity, 0.5); }
/* Borders */
.border { border: var(--border-size, 1px) solid var(--border-color, var(--color-subtle)); }
.border-top { border-top: var(--border-size, 1px) solid var(--border-color, var(--color-subtle)); }
.border { border: var(--border-size, 1px) var(--border-style, solid) var(--border-color, var(--color-subtle)); }
.border-block { border-block: var(--border-size, 1px) var(--border-style, solid) var(--border-color, var(--color-subtle)); }
.border-bottom { border-block-end: var(--border-size, 1px) var(--border-style, solid) var(--border-color, var(--color-subtle)); }
.border-top { border-block-start: var(--border-size, 1px) var(--border-style, solid) var(--border-color, var(--color-subtle)); }
.borderless { border: 0; }
/* Border radius */
+5
View File
@@ -0,0 +1,5 @@
module TimeHelper
def local_datetime_tag(datetime, style: :time, **attributes)
tag.time **attributes, datetime: datetime.iso8601, data: { local_time_target: style }
end
end
@@ -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)
}
}
+1 -2
View File
@@ -1,4 +1,3 @@
<%= button_to bucket_bubble_publish_path(bubble.bucket, bubble), class: "btn btn--positive full-width justify-start borderless" do %>
<%= image_tag "alert.svg", aria: { hidden: true }, size: 24 %>
<%= button_to bucket_bubble_publish_path(bubble.bucket, bubble), class: "btn txt-small btn--link" do %>
<span>Publish</span>
<% end %>
+8 -13
View File
@@ -34,20 +34,15 @@
</section>
<section class="bubbles-list unpad-inline center margin-block-start">
<ul class="unpad margin-none flex flex-column txt-align-start center">
<li class="bubbles-list__divider flex align-center gap-half full-width">
<button class="bubbles-list__handle btn btn--reversed">
<%= image_tag "drag.svg", aria: { hidden: true }, size: 16 %>
<span class="for-screen-reader">Drag to change number of bubbles shown above</span>
</button>
<hr class="separator--horizontal flex-item-grow">
<strong class="bubbles-list__count border-radius">
Top 10
</strong>
<hr class="separator--horizontal flex-item-grow">
<span></span>
</li>
<ul class="unpad margin-none flex flex-column txt-align-start center"
data-controller="divider"
data-divider-start-count-value="10"
data-divider-max-count-value="10"
data-divider-installed-class="bubbles-list__divider--installed"
data-divider-dragging-class="dragging"
data-action="turbo:morph@document->divider#install dragstart->divider#configureDrag dragenter->divider#acceptDrop dragover->divider#acceptDrop dragover->divider#moveDivider drop->divider#drop">
<%= render partial: "bubbles/list/bubble", collection: @bubbles, cached: true %>
<%= render "bubbles/list/divider", filter: @filter %>
</ul>
</section>
+1 -1
View File
@@ -1,4 +1,4 @@
<li class="flex align-center gap-half margin-none" style="--bubble-color: <%= bubble.color %>; <%= bubble_rotation(bubble) %>"
<li class="<%= class_names("flex align-center gap-half margin-none", drafted: bubble.drafted?) %>" style="--bubble-color: <%= bubble.color %>; <%= bubble_rotation(bubble) %>"
data-controller="animation" data-animation-play-class="bubble--wobble" data-animation-play-on-load-value="true" data-action="mouseover->animation#play">
<div class="bubble__shape flex-item-no-shrink"></div>
+13
View File
@@ -0,0 +1,13 @@
<li class="bubbles-list__divider flex align-center gap-half full-width" draggable="true" data-divider-target="divider">
<div class="divider-drag-image" data-divider-target="dragImage">Drag up or down</div>
<button class="bubbles-list__handle btn btn--reversed">
<%= image_tag "drag.svg", aria: { hidden: true }, size: 16 %>
<span class="for-screen-reader">Drag to change number of bubbles shown above</span>
</button>
<hr class="separator--horizontal flex-item-grow">
<strong class="bubbles-list__count border-radius">
Top <span data-divider-target="count">10</span>
</strong>
<hr class="separator--horizontal flex-item-grow">
<span></span>
</li>
+9 -3
View File
@@ -1,6 +1,14 @@
<% @page_title = @bubble.title %>
<% content_for :header do %>
<% if @bubble.drafted? %>
<div class="flex align-center gap-half fill-selected justify-center border-block margin-block-end"
style="--border-color: var(--color-selected-dark); --border-style: dashed; view-transition-name: draft-banner;">
This a draft, its only visible to you.
<%= render "bubbles/publish", bubble: @bubble %>
</div>
<% end %>
<nav class="align-start">
<%= link_to "#", class: "btn flex-item-justify-start", data: { controller: "hotkey back-navigation", action: "keydown.esc@document->hotkey#click", back_navigation_fallback_destination_value: bubbles_path(bucket_id: @bucket) } do %>
<%= image_tag "arrow-left.svg", aria: { hidden: true }, size: 24 %>
@@ -56,9 +64,7 @@
<% end %>
<hr class="separator--horizontal full-width" style="--border-color: var(--color-subtle);" />
<% if @bubble.drafted? %>
<%= render "bubbles/publish", bubble: @bubble %>
<% else %>
<% unless @bubble.drafted? %>
<%= render "bubbles/pop_toggle", bubble: @bubble %>
<button class="btn full-width justify-start borderless">
<%= image_tag "picture-double.svg", aria: { hidden: true }, size: 24 %>
@@ -1,4 +1,4 @@
<div name="assignments" class="position-relative" data-controller="dialog" data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside">
<div class="position-relative" data-controller="dialog" data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside">
<button class="btn full-width justify-start borderless" data-action="click->dialog#open:stop">
<%= image_tag "person.svg", aria: { hidden: true }, size: 24 %>
<span>Assign to someone</span>
+1 -1
View File
@@ -1,4 +1,4 @@
<div name="assignments" class="position-relative" data-controller="dialog" data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside">
<div class="position-relative" data-controller="dialog" data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside">
<button class="btn full-width justify-start borderless" data-action="click->dialog#open:stop">
<%= image_tag "tag.svg", aria: { hidden: true }, size: 24 %>
<span>Tag this</span>
+1 -1
View File
@@ -3,7 +3,7 @@
<%= link_to bubbles_path(bucket_ids: [ bucket ]), class: "border border-radius margin-block-end-half windshield__container flex justify-center align-center position-relative" do %>
<div class="windshield bucket__windshield flex flex-wrap gap justify-center align-end" style="view-transition-name: windshield_<%= bucket.id %>">
<% bucket.bubbles.ordered_by_activity.limit(10).each do |bubble| %>
<div class="bubble" style="--bubble-color: <%= bubble.color %>; <%= bubble_rotation(bubble) %> <%= bubble_size(bubble) %>">
<div class="<%= class_names("bubble", drafted: bubble.drafted?) %>" style="--bubble-color: <%= bubble.color %>; <%= bubble_rotation(bubble) %> <%= bubble_size(bubble) %>">
<span class="bubble__shape"></span>
</div>
<% end %>
+1 -1
View File
@@ -19,7 +19,7 @@
<%= yield :head %>
</head>
<body data-controller="lightbox">
<body data-controller="lightbox local-time">
<header id="header">
<a href="#main-content" class="skip-navigation btn">Skip to main content</a>
<%= yield :header %>
@@ -7,7 +7,7 @@
<div class="flex flex-column txt-tight-lines">
<strong><%= notification_title(notification) %></strong>
<%= notification_body(notification) %> · <%= time_ago_in_words(notification.created_at) %> ago
<div><%= notification_body(notification) %> · <%= local_datetime_tag(notification.created_at, style: :ago) %></div>
</div>
</div>
<% end %>