lostcoords-ui 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +11 -3
- data/app/assets/tailwind/lostcoords/ui/popover.css +0 -52
- data/app/assets/tailwind/lostcoords/ui/sheet.css +1 -61
- data/app/helpers/lostcoords/ui/form_helper.rb +3 -2
- data/app/helpers/lostcoords/ui/structures_helper.rb +6 -4
- data/app/javascript/lostcoords/ui/controllers/lc_selection_controller.js +2 -2
- data/app/javascript/lostcoords/ui/controllers/lc_sheet_controller.js +13 -1
- data/lib/lostcoords/ui/form_builder.rb +32 -0
- data/lib/lostcoords/ui/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 471fb41889b06f5e2adebcead43c299a7da5930df990e1c021c16276d35b80a9
|
|
4
|
+
data.tar.gz: 5daa36f87b519492932c134a51756b6ad84767057f1c49157e5e8a1dc23d7e20
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a596e626cb38bb949e96a6ed087e0566ff5a874dbf8d5c6d23e1e631579a22e2b5f3f9eaa85afd370856d2639ce39393746c3b45954c38d149a1b558755442b4
|
|
7
|
+
data.tar.gz: 3cb8ebe401d0ffbf1cc2626cd9f98a4ec002e6715fcb189cd6329e5cddc3879ecd0149c21cdba1ffa747dd745bad31e0c49f7d11e90a707ac2f784f4f30fc38c
|
data/README.md
CHANGED
|
@@ -21,7 +21,7 @@ its gem version, so publishing a library update does not upgrade consumers.
|
|
|
21
21
|
## Install
|
|
22
22
|
|
|
23
23
|
```ruby
|
|
24
|
-
gem "lostcoords-ui", "~> 0.
|
|
24
|
+
gem "lostcoords-ui", "~> 0.7.0"
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
The engine adds its rendering helpers to host views, its JavaScript to the asset paths and its pins to
|
|
@@ -242,7 +242,7 @@ Use `--lc-card-bg`, `--lc-card-border`, `--lc-card-radius` and `--lc-card-shadow
|
|
|
242
242
|
Badge, icon badge, Alert and Toast share `neutral`, `primary`, `success`, `warning` and `destructive` tones.
|
|
243
243
|
|
|
244
244
|
`lc_toggle` renders a pressed button. Use `lc_toggle_group` with `lc_toggle_group_item` for mutually exclusive
|
|
245
|
-
choices and a native submitted value. Navigation uses `lc_button_link_to` and `aria-current`, so links keep their native behavior.
|
|
245
|
+
choices and a native submitted value; it is a radiogroup of radio buttons to assistive technology. Navigation uses `lc_button_link_to` and `aria-current`, so links keep their native behavior.
|
|
246
246
|
`lc_collapsible` expands content through a native `<details>` element. `lc_alert` renders persistent inline feedback;
|
|
247
247
|
`lc_toast` adds timed dismissal.
|
|
248
248
|
|
|
@@ -360,7 +360,7 @@ Every interactive component works from the keyboard without extra code:
|
|
|
360
360
|
|---|---|
|
|
361
361
|
| Menu | Arrow Down or Up on the button opens on the first or last item. Arrows move and wrap, Home and End jump, a letter jumps to the next item that starts with it, Enter or Space activates, Escape closes and returns focus. |
|
|
362
362
|
| Tabs | Arrows along the tab list's orientation, Home and End. Automatic tabs select on focus; manual tabs on Enter or Space. |
|
|
363
|
-
| Toggle group | One Tab stop. All four arrows move and select, wrapping; Left and Right follow the text direction. |
|
|
363
|
+
| Toggle group | One Tab stop. All four arrows move and select, wrapping, as native radios do; Left and Right follow the text direction. |
|
|
364
364
|
| Emoji picker | Arrow Down from the search enters the grid, arrows move through it by row, Arrow Up from the top row returns to the search, and Enter in the search picks the best match. The grid is one Tab stop. |
|
|
365
365
|
| Dialogs, sheets, popovers, menus, tooltips | Escape closes the topmost one and returns focus to what opened it; an open tooltip closes before the menu or dialog around it. Escape in a search field with text clears it first. |
|
|
366
366
|
|
|
@@ -405,6 +405,11 @@ ordered by `--lc-z-popover`, `--lc-z-overlay` and `--lc-z-toast`.
|
|
|
405
405
|
<%= turbo_stream.append "toasts" do %><%= lc_toast "Project saved." %><% end %>
|
|
406
406
|
```
|
|
407
407
|
|
|
408
|
+
While a modal dialog or sheet is open the page behind it can't scroll: the body is pinned with
|
|
409
|
+
`position: fixed` and marked `data-modal-scroll-lock="true"`, so `window.scrollY` reads 0 until it
|
|
410
|
+
closes. Scroll-driven chrome, such as a header that fills in once the page scrolls, should keep its
|
|
411
|
+
state while that attribute is set; the body's `--lc-scroll-lock-y` holds the real offset.
|
|
412
|
+
|
|
408
413
|
Two in-page layers remain: `z-sticky` keeps app chrome such as a sticky header above content, and
|
|
409
414
|
`z-raised` lifts an element over its neighbours. Put `isolate` on the main content area, so any z-index
|
|
410
415
|
inside it stays below the chrome. Tailwind drops a `z-*` class it can't resolve without a warning, so
|
|
@@ -614,6 +619,9 @@ bin/rails server # the dummy app in test/dummy
|
|
|
614
619
|
bin/rails tailwindcss:build
|
|
615
620
|
bin/test
|
|
616
621
|
bin/lint
|
|
622
|
+
npx playwright install chromium # once
|
|
623
|
+
bin/browser-test # every suite in test/browser, headless, against its own docs server
|
|
624
|
+
bin/browser-test sheets # one suite; BASE_URL=https://ui.localhost reuses a running server
|
|
617
625
|
```
|
|
618
626
|
|
|
619
627
|
The icon catalog and its MIT license live in `vendor/heroicons`. Regenerate them from the pinned
|
|
@@ -21,55 +21,3 @@
|
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
-
|
|
25
|
-
@layer components {
|
|
26
|
-
/* Desktop: the .lc-sheet becomes an anchored popover instead of a bottom sheet. */
|
|
27
|
-
@media (width >= 40rem) {
|
|
28
|
-
.lc-sheet--anchored {
|
|
29
|
-
@apply lc-popover-shell;
|
|
30
|
-
|
|
31
|
-
display: none;
|
|
32
|
-
flex-direction: column;
|
|
33
|
-
position: absolute;
|
|
34
|
-
inset-block: calc(100% + --spacing(2)) auto;
|
|
35
|
-
inset-inline: auto 0;
|
|
36
|
-
inline-size: min(24rem, calc(100vw - --spacing(8)));
|
|
37
|
-
max-block-size: min(28rem, calc(100vh - --spacing(24)));
|
|
38
|
-
padding-block-end: 0;
|
|
39
|
-
border-radius: var(--radius-3xl);
|
|
40
|
-
pointer-events: auto;
|
|
41
|
-
|
|
42
|
-
&[data-open] {
|
|
43
|
-
display: flex;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/* Enter/exit for small overlays, whichever way they open: a [hidden] toggle
|
|
49
|
-
(amount calculator, colour picker), the Popover API (phone nav menus) or a
|
|
50
|
-
modal <dialog>. The closed state is the exit pose, so close animates too;
|
|
51
|
-
allow-discrete holds display/overlay until it ends. --lc-pop-y sets the
|
|
52
|
-
direction: popovers drop from their trigger, dialogs rise. */
|
|
53
|
-
.lc-popover-pop {
|
|
54
|
-
--lc-pop-from: translateY(var(--lc-pop-y, -0.25rem)) scale(0.96);
|
|
55
|
-
|
|
56
|
-
transform-origin: var(--lc-pop-origin, top);
|
|
57
|
-
transition:
|
|
58
|
-
opacity 160ms var(--lc-ease-out-expo),
|
|
59
|
-
transform 240ms var(--lc-ease-out-expo),
|
|
60
|
-
display 240ms allow-discrete,
|
|
61
|
-
overlay 240ms allow-discrete;
|
|
62
|
-
|
|
63
|
-
&:is([hidden], [popover]:not(:popover-open), dialog:not([open])) {
|
|
64
|
-
opacity: 0;
|
|
65
|
-
transform: var(--lc-pop-from);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
&:is(:not([hidden], [popover], dialog), :popover-open, dialog[open]) {
|
|
69
|
-
@starting-style {
|
|
70
|
-
opacity: 0;
|
|
71
|
-
transform: var(--lc-pop-from);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}
|
|
@@ -1,65 +1,5 @@
|
|
|
1
1
|
@layer components {
|
|
2
2
|
@media (width < 40rem) {
|
|
3
|
-
.lc-sheet {
|
|
4
|
-
position: fixed;
|
|
5
|
-
|
|
6
|
-
/* Anchored from the top edge at the screen height it opened at
|
|
7
|
-
(lc-sheet), then lifted by its own height. In-app browsers
|
|
8
|
-
(Brave, Chrome) resize the page under the keyboard; anything hung
|
|
9
|
-
from the bottom edge jumps a frame as they do, but the top holds. */
|
|
10
|
-
inset: var(--lc-sheet-floor, 100dvh) 0 auto;
|
|
11
|
-
translate: 0 -100%;
|
|
12
|
-
margin: 0;
|
|
13
|
-
inline-size: 100%;
|
|
14
|
-
max-block-size: calc(var(--lc-sheet-floor, 100dvh) * 0.9);
|
|
15
|
-
overflow-y: auto;
|
|
16
|
-
overscroll-behavior: contain;
|
|
17
|
-
padding-block-end: max(env(safe-area-inset-bottom), 1rem);
|
|
18
|
-
border: 1px solid var(--lc-glass-edge);
|
|
19
|
-
border-start-start-radius: var(--radius-3xl);
|
|
20
|
-
border-start-end-radius: var(--radius-3xl);
|
|
21
|
-
|
|
22
|
-
/* Published as a variable, not just applied, because the sticky chrome
|
|
23
|
-
that rides on top of the sheet — the grabber bar, the pinned action
|
|
24
|
-
row — has to paint the *same* surface or it reads as a seam. In dark
|
|
25
|
-
mode the sheet is a lightened paper-2/paper-3 mix while --background is
|
|
26
|
-
0.045 lightness darker, which is exactly the band you could see. */
|
|
27
|
-
--lc-sheet-bg: var(--background);
|
|
28
|
-
@supports (background: light-dark(white, black)) {
|
|
29
|
-
--lc-sheet-bg: light-dark(var(--background), color-mix(in oklab, var(--muted) 92%, var(--accent)));
|
|
30
|
-
}
|
|
31
|
-
background: var(--lc-sheet-bg);
|
|
32
|
-
color: var(--foreground);
|
|
33
|
-
box-shadow: 0 -12px 40px -12px color-mix(in oklab, var(--foreground) 45%, transparent);
|
|
34
|
-
transform: translateY(var(--lc-sheet-offscreen));
|
|
35
|
-
transition:
|
|
36
|
-
transform var(--lc-sheet-duration) var(--lc-ease-sheet),
|
|
37
|
-
display var(--lc-sheet-duration) allow-discrete,
|
|
38
|
-
overlay var(--lc-sheet-duration) allow-discrete;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/* Popover-API reveal (currency): top layer drives display, so showPopover()/
|
|
42
|
-
hidePopover() animate the slide both ways, with a native ::backdrop. */
|
|
43
|
-
.lc-sheet[popover] { display: none; flex-direction: column; max-block-size: min(calc(var(--lc-sheet-floor, 100svh) * 0.72), 34rem); }
|
|
44
|
-
.lc-sheet[popover]:popover-open {
|
|
45
|
-
display: flex;
|
|
46
|
-
transform: translateY(0);
|
|
47
|
-
|
|
48
|
-
@starting-style { transform: translateY(var(--lc-sheet-offscreen)); }
|
|
49
|
-
}
|
|
50
|
-
.lc-sheet[popover]::backdrop {
|
|
51
|
-
background: var(--lc-scrim);
|
|
52
|
-
backdrop-filter: blur(2px);
|
|
53
|
-
opacity: 0;
|
|
54
|
-
transition:
|
|
55
|
-
opacity var(--lc-sheet-duration) var(--lc-ease-sheet),
|
|
56
|
-
display var(--lc-sheet-duration) allow-discrete,
|
|
57
|
-
overlay var(--lc-sheet-duration) allow-discrete;
|
|
58
|
-
|
|
59
|
-
@media (prefers-reduced-motion: reduce) { transition: none; }
|
|
60
|
-
}
|
|
61
|
-
.lc-sheet[popover]:popover-open::backdrop { opacity: 1; @starting-style { opacity: 0; } }
|
|
62
|
-
|
|
63
3
|
/* UIKit's 36×5 grabber. */
|
|
64
4
|
:is([data-lc-sheet-target~="handle"], dialog[data-controller~="lc-sheet"])::before {
|
|
65
5
|
content: "";
|
|
@@ -87,7 +27,7 @@
|
|
|
87
27
|
|
|
88
28
|
position: fixed;
|
|
89
29
|
|
|
90
|
-
/* Anchored from the top edge
|
|
30
|
+
/* Anchored from the top edge: the keyboard covers it and
|
|
91
31
|
nothing moves. */
|
|
92
32
|
inset: calc(var(--lc-sheet-floor, 100dvh) - var(--lc-sheet-gap)) 0 auto;
|
|
93
33
|
translate: 0 -100%;
|
|
@@ -7,12 +7,13 @@ module Lostcoords::Ui
|
|
|
7
7
|
"lc-press inline-flex items-center justify-center gap-1.5 whitespace-nowrap " \
|
|
8
8
|
"h-control-sm rounded-xl border-0 bg-transparent " \
|
|
9
9
|
"px-3 font-sans text-label cursor-pointer no-underline " \
|
|
10
|
-
"text-muted-foreground hover:text-foreground " \
|
|
10
|
+
"text-muted-foreground not-disabled:hover:text-foreground " \
|
|
11
11
|
"aria-selected:bg-background aria-selected:text-foreground " \
|
|
12
|
+
"aria-checked:bg-background aria-checked:text-foreground " \
|
|
12
13
|
"aria-pressed:bg-background aria-pressed:text-foreground " \
|
|
13
14
|
"aria-[current]:not-aria-[current=false]:bg-background aria-[current]:not-aria-[current=false]:text-foreground " \
|
|
14
15
|
"transition-[background-color,color,box-shadow,transform] duration-(--lc-motion-quick) ease-(--lc-ease-out) " \
|
|
15
|
-
"disabled:
|
|
16
|
+
"disabled:cursor-not-allowed disabled:text-[color-mix(in_oklab,var(--foreground)_38%,transparent)] " \
|
|
16
17
|
"[&_svg]:size-[1.1em] [&_svg]:shrink-0"
|
|
17
18
|
|
|
18
19
|
TOGGLE_GROUP_ITEM_ACTIVE = "bg-background text-foreground hover:text-foreground"
|
|
@@ -35,10 +35,12 @@ module Lostcoords::Ui
|
|
|
35
35
|
end
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
# One choice from a few options, as a segmented control. Arrows move and select, like native radios,
|
|
39
|
+
# so it is a radiogroup of radio buttons to assistive technology.
|
|
38
40
|
def lc_toggle_group(label:, name: nil, value: nil, **attributes, &block)
|
|
39
41
|
lc_label!(label, :label)
|
|
40
|
-
defaults = { class: lc_toggle_group_class, role: "
|
|
41
|
-
tag.div(**lc_attributes(defaults, attributes, { role: "
|
|
42
|
+
defaults = { class: lc_toggle_group_class, role: "radiogroup", aria: { label: label }, data: { slot: "toggle-group", controller: "lc-selection", action: "click->lc-selection#select keydown->lc-selection#navigate", lc_selection_value_value: value } }
|
|
43
|
+
tag.div(**lc_attributes(defaults, attributes, { role: "radiogroup", aria: { label: label } })) do
|
|
42
44
|
safe_join([ (hidden_field_tag(name, value, data: { lc_selection_target: "input" }) if name), (capture(&block) if block) ].compact)
|
|
43
45
|
end
|
|
44
46
|
end
|
|
@@ -46,8 +48,8 @@ module Lostcoords::Ui
|
|
|
46
48
|
def lc_toggle_group_item(label = nil, value: nil, selected: false, **attributes, &block)
|
|
47
49
|
value ||= label
|
|
48
50
|
lc_label!(value, :value)
|
|
49
|
-
defaults = { class: lc_toggle_group_item_class,
|
|
50
|
-
required = { type: "button", data: { lc_selection_target: "option", value: value } }
|
|
51
|
+
defaults = { class: lc_toggle_group_item_class, aria: { checked: selected }, data: { slot: "toggle-group-item" } }
|
|
52
|
+
required = { type: "button", role: "radio", data: { lc_selection_target: "option", value: value } }
|
|
51
53
|
tag.button(block ? capture(&block) : label, **lc_attributes(defaults, attributes, required))
|
|
52
54
|
end
|
|
53
55
|
|
|
@@ -9,7 +9,7 @@ export default class extends Controller {
|
|
|
9
9
|
const selected = this.options.find((option) =>
|
|
10
10
|
this.hasValueValue
|
|
11
11
|
? option.dataset.value === this.valueValue
|
|
12
|
-
: option.getAttribute("aria-
|
|
12
|
+
: option.getAttribute("aria-checked") === "true",
|
|
13
13
|
)
|
|
14
14
|
this.initialOption = selected || this.options[0]
|
|
15
15
|
this.apply(this.initialOption)
|
|
@@ -41,7 +41,7 @@ export default class extends Controller {
|
|
|
41
41
|
|
|
42
42
|
apply(selected) {
|
|
43
43
|
for (const option of this.optionTargets) {
|
|
44
|
-
option.setAttribute("aria-
|
|
44
|
+
option.setAttribute("aria-checked", String(option === selected))
|
|
45
45
|
option.tabIndex = option === selected ? 0 : -1
|
|
46
46
|
}
|
|
47
47
|
if (this.hasInputTarget) this.inputTarget.value = selected?.dataset.value || ""
|
|
@@ -155,8 +155,11 @@ export default class extends Controller {
|
|
|
155
155
|
if (this.gesture?.dragging) this.track(event.clientY, event.timeStamp)
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
|
|
158
|
+
// A mouse drag that ends off the sheet is followed by a click on the
|
|
159
|
+
// <dialog> itself, which reads as a backdrop click and would close it.
|
|
160
|
+
onPointerUp(event) {
|
|
159
161
|
this.stopMouseTracking()
|
|
162
|
+
if (this.gesture && Math.abs(event.clientY - this.gesture.startY) > 4) swallowNextClick()
|
|
160
163
|
this.onTouchEnd()
|
|
161
164
|
}
|
|
162
165
|
|
|
@@ -420,3 +423,12 @@ export default class extends Controller {
|
|
|
420
423
|
return isSheetViewport()
|
|
421
424
|
}
|
|
422
425
|
}
|
|
426
|
+
|
|
427
|
+
function swallowNextClick() {
|
|
428
|
+
const swallow = (event) => {
|
|
429
|
+
event.preventDefault()
|
|
430
|
+
event.stopImmediatePropagation()
|
|
431
|
+
}
|
|
432
|
+
window.addEventListener("click", swallow, { capture: true, once: true })
|
|
433
|
+
setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0)
|
|
434
|
+
}
|
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
module Lostcoords::Ui
|
|
2
|
+
# Rails wraps a field with errors in <div class="field_with_errors">. Controls this builder renders show
|
|
3
|
+
# errors themselves, so the wrapper is skipped while it renders; other forms keep Rails' behaviour.
|
|
4
|
+
module SkipsFieldErrorWrapper
|
|
5
|
+
def error_wrapping(html_tag)
|
|
6
|
+
@template_object.instance_variable_get(:@lc_skip_field_error_wrapper) ? html_tag : super
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
ActionView::Helpers::Tags::Base.prepend(SkipsFieldErrorWrapper)
|
|
10
|
+
|
|
2
11
|
# Rails form builder that renders the library's controls and connects model errors to them.
|
|
3
12
|
#
|
|
4
13
|
# <%= lc_form_with model: @project do |form| %>
|
|
@@ -16,6 +25,21 @@ module Lostcoords::Ui
|
|
|
16
25
|
date_field time_field datetime_field datetime_local_field month_field week_field
|
|
17
26
|
].freeze
|
|
18
27
|
|
|
28
|
+
# Every helper that renders a control, wrapped so Rails' error wrapper stays out of its markup.
|
|
29
|
+
WRAPPED = [ *field_helpers, :select, :collection_select, :grouped_collection_select, :time_zone_select,
|
|
30
|
+
:collection_check_boxes, :collection_radio_buttons, :switch, :choice ].uniq - %i[label fields_for fields hidden_field]
|
|
31
|
+
|
|
32
|
+
module WithoutFieldErrorWrapper
|
|
33
|
+
WRAPPED.each do |helper|
|
|
34
|
+
module_eval <<~RUBY, __FILE__, __LINE__ + 1
|
|
35
|
+
def #{helper}(...)
|
|
36
|
+
without_field_error_wrapper { super(...) }
|
|
37
|
+
end
|
|
38
|
+
RUBY
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
prepend WithoutFieldErrorWrapper
|
|
42
|
+
|
|
19
43
|
INPUTS.each do |selector|
|
|
20
44
|
define_method(selector) do |method, options = {}|
|
|
21
45
|
size = control_size!(options)
|
|
@@ -95,6 +119,14 @@ module Lostcoords::Ui
|
|
|
95
119
|
end
|
|
96
120
|
|
|
97
121
|
private
|
|
122
|
+
def without_field_error_wrapper
|
|
123
|
+
previous = @template.instance_variable_get(:@lc_skip_field_error_wrapper)
|
|
124
|
+
@template.instance_variable_set(:@lc_skip_field_error_wrapper, true)
|
|
125
|
+
yield
|
|
126
|
+
ensure
|
|
127
|
+
@template.instance_variable_set(:@lc_skip_field_error_wrapper, previous)
|
|
128
|
+
end
|
|
129
|
+
|
|
98
130
|
def control_size!(options)
|
|
99
131
|
options[:size].is_a?(Symbol) || options[:size].is_a?(String) && options[:size].match?(/\A[a-z]+\z/) ? options.delete(:size).to_sym : :md
|
|
100
132
|
end
|