Wire up knob UI design

This commit is contained in:
Jason Zimdars
2025-06-04 20:28:20 -05:00
parent 10224c2698
commit e42c6548b0
6 changed files with 233 additions and 13 deletions
+142
View File
@@ -0,0 +1,142 @@
@layer components {
.knob {
--knob-angle-reserve: 120deg;
--knob-option-angle: calc((360deg - var(--knob-angle-reserve)) / (var(--knob-options) - 1));
--knob-option-size: 3ch;
--knob-chamfer-size: 1ch;
--knob-color: oklch(var(--lch-ink-light));
--knob-color-accent: oklch(var(--lch-blue-medium));
--knob-tick-size: 1ch;
--knob-radius: calc(var(--knob-size) / 2);
--knob-size: 96px;
border: none;
display: block;
font-weight: 500;
padding: var(--knob-option-size) 0 0;
position: relative;
text-align: center;
}
.knob__slider {
--hover-size: 0;
appearance: none;
background-color: transparent;
block-size: var(--knob-size);
inline-size: var(--knob-size);
inset: 50% auto auto 50%;
position: absolute;
translate: -50% -50%;
z-index: 1;
&::-webkit-slider-runnable-track {
block-size: var(--knob-size);
cursor: grab;
}
&::-webkit-slider-thumb {
appearance: none;
background-color: transparent;
height: 1px;
width: 1px;
}
}
.knob__option {
block-size: var(--knob-option-size);
border-radius: 50%;
cursor: pointer;
display: grid;
inline-size: var(--knob-option-size);
inset: 50% auto auto 50%;
place-content: center;
position: absolute;
transform:
translate(-50%, -50%)
rotate(calc(-1 * ((360deg - var(--knob-angle-reserve)) / 2) + (var(--knob-option-angle) * var(--i))))
translateY(calc(-1 * var(--knob-radius) - 50% - var(--knob-tick-size)));
z-index: 1;
&:hover,
&:has(input:checked) {
color: var(--knob-color-accent);
}
&:has(:focus-visible) {
box-shadow: 0 0 0 2px var(--knob-color);
}
/* Tick marks */
&:before {
background-color: var(--knob-color);
block-size: var(--knob-tick-size);
content: "";
inline-size: 2px;
inset: 100% auto auto 50%;
position: absolute;
translate: -50% 0;
}
/* The value text */
span {
rotate: calc(((360deg - var(--knob-angle-reserve)) / 2) - (var(--knob-option-angle) * var(--i)));
}
input {
opacity: 0;
position: absolute;
}
}
.knob__knob {
background: linear-gradient(to top, var(--knob-color), color-mix(in oklch, var(--knob-color) 50%, var(--color-canvas) 50%));
block-size: var(--knob-size);
border-radius: 50%;
box-shadow:
0 0 2px 1px rgba(0,0,0,0.10),
0 2px 4px rgba(0,0,0,0.15),
0 2px 8px rgba(0,0,0,0.20);
inline-size: var(--knob-size);
margin-inline: auto;
position: relative;
&:before,
&:after {
content: "";
position: absolute;
}
/* Indent */
&:before {
background: linear-gradient(to bottom, var(--knob-color), color-mix(in oklch, var(--knob-color) 50%, var(--color-canvas) 50%));
block-size: calc(var(--knob-size) - var(--knob-chamfer-size));
border-radius: 50%;
box-shadow:
0 -1px 0 rgba(255, 255, 255, 0.25),
inset 0 -1px 0 rgba(255, 255, 255, 0.25);
inline-size: calc(var(--knob-size) - var(--knob-chamfer-size));
inset: 50% auto auto 50%;
translate: -50% -50%;
}
/* Indicator */
&:after {
background-color: var(--color-ink);
block-size: calc(var(--knob-radius) - var(--knob-chamfer-size) / 2);
border-radius: 50% 50% 2px 2px;
inline-size: 4px;
inset: auto auto 50% 50%;
rotate: calc(-1 * ((360deg - var(--knob-angle-reserve)) / 2) + (var(--knob-option-angle) * var(--knob-index)));
transform-origin: center bottom;
transition: rotate 100ms;
translate: -50% 0;
}
}
.knob__label {
font-weight: bold;
margin-block-start: 1ch;
text-transform: uppercase;
}
}
+6 -5
View File
@@ -1,11 +1,12 @@
module CollectionsHelper
def collection_auto_close_options
[
[ "3 days", 3.days ],
[ "7 days", 7.days ],
[ "30 days", 30.days ],
[ "90 days", 90.days ],
[ "365 days", 365.days ]
[ "3", 3.days ],
[ "7", 7.days ],
[ "11", 11.days ],
[ "30", 30.days ],
[ "90", 90.days ],
[ "365", 365.days ]
]
end
@@ -0,0 +1,28 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ "field", "option", "slider" ]
optionChanged(e) {
this.#setIndex(e.target.getAttribute("data-index"))
}
sliderChanged(e) {
const index = e.target.value
this.#setIndex(index)
this.#setValue(index)
}
#setIndex(index) {
this.fieldTarget.style.setProperty("--knob-index", `${index}`);
this.sliderTarget.value = index;
}
#setValue(index) {
const option = this.optionTargets.find(option => {
return option.dataset.index === index;
});
option.checked = true;
}
}
+1 -2
View File
@@ -32,10 +32,9 @@
<div class="panel shadow center flex flex-column">
<%= render "collections/edit/workflows", collection: @collection %>
<%= render "collections/edit/auto_close", collection: @collection %>
<hr class="separator--horizontal full-width margin-block-end" style="--border-color: var(--color-ink-medium); margin-block-start: auto;" aria-hidden="true" />
<hr class="separator--horizontal full-width margin-block-end margin-block-start-double" style="--border-color: var(--color-ink-light);" aria-hidden="true" />
<%= form_with model: @collection, class: "txt-align-center", method: :delete do |form| %>
<%= form.button class: "btn txt-negative borderless txt-small", data: { turbo_confirm: "Are you sure you want to permanently delete this Collection?" } do %>
+14 -6
View File
@@ -3,17 +3,25 @@
<div class="flex align-start gap">
<div class="flex flex-column full-width" style="flex-basis: 48%">
<strong>Cards in Considering</strong>
<p class="txt-x-small margin-none">Auto-close after…</p>
<%= form_with model: model, url: url, data: { controller: "form" } do |form| %>
<%= form.select :auto_reconsider_period, collection_auto_close_options, {}, { class: "input input--select full-width margin-block-half txt-small fill-white txt-align-center", data: { action: "change->form#submit" } } %>
<p class="txt-x-small margin-none-block-start">Auto-close after…</p>
<%= form_with model: model, url: url || collection_path(model), data: { controller: "form" } do |form| %>
<%= render "entropy/knob",
knob_options: collection_auto_close_options.map(&:first).map(&:to_i),
label: "Days until auto-close",
period: model.auto_close_period,
field_name: url ? "entropy_configuration[auto_close_period]" : "collection[auto_close_period]" %>
<% end %>
</div>
<div class="separator margin-block-half" style="--border-color: var(--color-ink-light); min-block-size: 3lh;"></div>
<div class="flex flex-column full-width" style="flex-basis: 48%">
<strong>Cards in Doing</strong>
<p class="txt-x-small margin-none">Fall back to Considering after…</p>
<%= form_with model: model, url: url, data: { controller: "form" } do |form| %>
<%= form.select :auto_close_period, collection_auto_close_options, {}, { class: "input input--select full-width margin-block-half txt-small fill-white txt-align-center", data: { action: "change->form#submit" } } %>
<p class="txt-x-small margin-none-block-start">Fall back to Considering after…</p>
<%= form_with model: model, url: url || collection_path(model), data: { controller: "form" } do |form| %>
<%= render "entropy/knob",
knob_options: collection_auto_close_options.map(&:first).map(&:to_i),
label: "Days until auto-close",
period: model.auto_reconsider_period,
field_name: url ? "entropy_configuration[auto_reconsider_period]" : "collection[auto_reconsider_period]" %>
<% end %>
</div>
</div>
+42
View File
@@ -0,0 +1,42 @@
<%
name = label.parameterize
default_index = if period
knob_options.index(period.to_i / 1.day)
else
knob_options.index(Entropy::Configuration.default.auto_close_period.to_i / 1.day)
end
%>
<fieldset class="knob" data-controller="knob" data-knob-target="field" style="--knob-options: <%= knob_options.length %>; --knob-index: <%= default_index %>;">
<div class="position-relative" role="radiogroup" aria-labelledby="<%= name %>">
<% knob_options.each_with_index do |value, i| %>
<label class="knob__option" style="--i: <%= i %>">
<input
<%= "checked" if value == knob_options[default_index] %>
data-action="change->knob#optionChanged change->form#submit"
data-index="<%= i %>"
data-knob-target="option"
name="<%= field_name %>"
type="radio"
value="<%= value.days %>"
/>
<span><%= value %></span>
</label>
<% end %>
<input
aria-hidden
class="knob__slider"
data-action="input->knob#sliderChanged change->form#submit"
data-knob-target="slider"
max="<%= knob_options.length - 1 %>"
min="0"
type="range"
value="<%= default_index %>"
/>
<div class="knob__knob" aria-hidden></div>
</div>
<div id="<%= name %>" class="knob__label">Days</div>
</fieldset>