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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: be63d4a81a906b754a7dda9d321d5e0d79b45ac4ca18b39dc3e542d8b10a9822
4
- data.tar.gz: 2073ae1b254f4f6aa61c4665bf1d3e4123d756b0bcfdb345ee1f5182452f6c14
3
+ metadata.gz: 471fb41889b06f5e2adebcead43c299a7da5930df990e1c021c16276d35b80a9
4
+ data.tar.gz: 5daa36f87b519492932c134a51756b6ad84767057f1c49157e5e8a1dc23d7e20
5
5
  SHA512:
6
- metadata.gz: 7c00c795e03e402e554532c542b4d6ac748b9efb61dad9e057b5698a99a70ac9f49671f211ce7fd467d5b2d12693d63d2243583f6bd94bd454da2cd24b77ba13
7
- data.tar.gz: 9333e9c1cdf9a7e7a35ca00ad411be173578203af5c82deedd1f432e812361a9b01f22e74f5234aae75351a1e115e57cb1675b3b76239c6eae770a89106144d7
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.6.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, like .lc-sheet: the keyboard covers it and
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:opacity-40 disabled:pointer-events-none " \
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: "group", aria: { label: label }, data: { slot: "toggle-group", controller: "lc-selection", action: "click->lc-selection#select keydown->lc-selection#navigate", lc_selection_value_value: value } }
41
- tag.div(**lc_attributes(defaults, attributes, { role: "group", aria: { label: label } })) do
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, type: "button", aria: { pressed: selected }, data: { slot: "toggle-group-item", lc_selection_target: "option", value: value } }
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-pressed") === "true",
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-pressed", String(option === selected))
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
- onPointerUp() {
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
@@ -1,5 +1,5 @@
1
1
  module Lostcoords
2
2
  module Ui
3
- VERSION = "0.6.0"
3
+ VERSION = "0.7.0"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lostcoords-ui
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lost Coordinates