diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index 073508bff6..d3b671b9aa 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -281,10 +281,7 @@ /* Overlap the card BG by half the button height */ &:has(.btn) { - &, - ~ .card-perma__closure-message { - translate: 0 calc(-1 * var(--half-btn-height)); - } + translate: 0 calc(-1 * var(--half-btn-height)); } form { @@ -297,12 +294,6 @@ --btn-color: var(--color-ink-inverted); } - .btn--plain { - --btn-color: var(--card-color); - - text-decoration: underline; - } - .btn--reversed { --btn-background: var(--color-canvas); --btn-color: var(--card-color); @@ -330,11 +321,25 @@ .card-perma__closure-message { color: var(--card-color); grid-area: closure-message; - margin-block-start: 0.5ch; + margin-block: var(--block-space) var(--block-space-double); padding-inline: 1ch; - form { - display: inline; + .btn--plain { + --btn-color: var(--card-color); + + text-decoration: underline; + } + + @media (max-width: 799px) { + margin-block: var(--block-space-half); + translate: 0 calc(-0.5 * var(--half-btn-height)); + } + + @media (min-width: 800px) { + .card-perma__notch--bottom:has(.btn) ~ & { + margin-block: var(--block-space-half) var(--block-space); + translate: 0 calc(-0.5 * var(--half-btn-height)); + } } } } diff --git a/app/assets/stylesheets/native.css b/app/assets/stylesheets/native.css index 96df0e00ba..8226a52612 100644 --- a/app/assets/stylesheets/native.css +++ b/app/assets/stylesheets/native.css @@ -6,6 +6,8 @@ --custom-safe-inset-bottom: var(--injected-safe-inset-bottom, env(safe-area-inset-bottom, 0px)); --custom-safe-inset-left: var(--injected-safe-inset-left, env(safe-area-inset-left, 0px)); + --footer-height: 0; + -webkit-tap-highlight-color: transparent; .hide-on-native { @@ -28,11 +30,27 @@ } } + /* Card columns + /* ------------------------------------------------------------------------ */ + + .board-tools.card { + padding-block-start: 0; + } + /* Card perma /* ------------------------------------------------------------------------ */ .card-perma { margin-block-start: 0; + + &:not(:has(.card-perma__notch-new-card-buttons)) .card-perma__bg { + padding-block: clamp(0.25rem, 2vw, var(--padding-block)); + } + } + + .card-perma__closure-message { + margin-block: var(--block-space); + translate: unset; } /* Search @@ -43,3 +61,25 @@ } } } + +[data-bridge-components~=form] { + [data-controller~=bridge--form] { + [data-bridge--form-target~=submit] { + display: none; + } + } +} + +[data-bridge-components~=overflow-menu] { + [data-controller~=bridge--overflow-menu] { + [data-bridge--overflow-menu-target~=item] { + display: none; + } + } +} + +[data-bridge-components~=buttons] { + [data-bridge--buttons-target~=button] { + display: none; + } +} diff --git a/app/assets/stylesheets/popup.css b/app/assets/stylesheets/popup.css index e27494153b..aa0b8c1daf 100644 --- a/app/assets/stylesheets/popup.css +++ b/app/assets/stylesheets/popup.css @@ -53,6 +53,10 @@ .popup__title { font-weight: 800; white-space: nowrap; + + &[tabindex="-1"]:focus-visible { + outline: unset; + } } /* Hide lists when all the items within are hidden */ diff --git a/app/helpers/accesses_helper.rb b/app/helpers/accesses_helper.rb index 1ace03574d..c5a4ae1c62 100644 --- a/app/helpers/accesses_helper.rb +++ b/app/helpers/accesses_helper.rb @@ -48,7 +48,8 @@ def involvement_button(board, access, show_watchers, icon_only) params: { show_watchers: show_watchers, involvement: next_involvement(access.involvement), icon_only: icon_only }, aria: { labelledby: dom_id(board, :involvement_label) }, title: (label_text if icon_only), - class: class_names("btn", { "btn--reversed": access.watching? && icon_only })) do + class: class_names("btn", { "btn--reversed": access.watching? && icon_only }), + data: !icon_only && { bridge__overflow_menu_target: "item", bridge_title: label_text }) do icon_tag("notification-bell-#{icon_only ? 'reverse-' : nil}#{access.involvement.dasherize}") + tag.span(label_text, class: class_names("txt-nowrap txt-uppercase", "for-screen-reader": icon_only), id: dom_id(board, :involvement_label)) end diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb index a1960b3410..9b9ee89b36 100644 --- a/app/helpers/boards_helper.rb +++ b/app/helpers/boards_helper.rb @@ -4,7 +4,8 @@ def link_back_to_board(board) end def link_to_edit_board(board) - link_to edit_board_path(board), class: "btn", data: { controller: "tooltip" } do + link_to edit_board_path(board), class: "btn", + data: { controller: "tooltip", bridge__overflow_menu_target: "item", bridge_title: "Board settings" } do icon_tag("settings") + tag.span("Settings for #{board.name}", class: "for-screen-reader") end end diff --git a/app/helpers/bridge_helper.rb b/app/helpers/bridge_helper.rb new file mode 100644 index 0000000000..5fb4508b69 --- /dev/null +++ b/app/helpers/bridge_helper.rb @@ -0,0 +1,5 @@ +module BridgeHelper + def bridge_icon(name) + asset_url("#{name}.svg") + end +end diff --git a/app/helpers/filters_helper.rb b/app/helpers/filters_helper.rb index 6c3f01725e..a0529b11a1 100644 --- a/app/helpers/filters_helper.rb +++ b/app/helpers/filters_helper.rb @@ -40,6 +40,10 @@ def filter_dialog(label, &block) }, &block end + def filter_title(title) + tag.strong title, class: "popup__title pad-inline-half", tabindex: "-1", data: { dialog_target: "focusTouch" } + end + def collapsible_nav_section(title, **properties, &block) tag.details class: "nav__section popup__section", data: { action: "toggle->nav-section-expander#toggle", nav_section_expander_target: "section", nav_section_expander_key_value: title.parameterize }, open: true, **properties do concat(tag.summary(class: "popup__section-title") do diff --git a/app/helpers/forms_helper.rb b/app/helpers/forms_helper.rb index 095655e3b6..2902f809d8 100644 --- a/app/helpers/forms_helper.rb +++ b/app/helpers/forms_helper.rb @@ -9,4 +9,23 @@ def auto_submit_form_with(**attributes, &) form_with(**attributes, data: data) { } end end + + def bridged_form_with(**attributes, &) + data = attributes.delete(:data) || {} + controllers = [ data[:controller], "bridge--form" ].compact.join(" ").strip + actions = [ + data[:action], + "turbo:submit-start->bridge--form#submitStart", + "turbo:submit-end->bridge--form#submitEnd" + ].compact.join(" ").strip + + data[:controller] = controllers + data[:action] = actions + + if block_given? + form_with **attributes, data: data, & + else + form_with(**attributes, data: data) { } + end + end end diff --git a/app/helpers/webhooks_helper.rb b/app/helpers/webhooks_helper.rb index f3bb077c83..a2e14af29a 100644 --- a/app/helpers/webhooks_helper.rb +++ b/app/helpers/webhooks_helper.rb @@ -25,7 +25,7 @@ def webhook_action_label(action) def link_to_webhooks(board, &) link_to board_webhooks_path(board_id: board), class: [ "btn", { "btn--reversed": board.webhooks.any? } ], - data: { controller: "tooltip" } do + data: { controller: "tooltip", bridge__overflow_menu_target: "item", bridge_title: "Webhooks" } do icon_tag("world") + tag.span("Webhooks", class: "for-screen-reader") end end diff --git a/app/javascript/application.js b/app/javascript/application.js index bdafb95b09..c43a45eaa6 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -1,8 +1,8 @@ // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails import "@hotwired/turbo-rails" +import "@hotwired/hotwire-native-bridge" import "initializers" import "controllers" import "lexxy" import "@rails/actiontext" - diff --git a/app/javascript/controllers/bridge/buttons_controller.js b/app/javascript/controllers/bridge/buttons_controller.js new file mode 100644 index 0000000000..47d09a158f --- /dev/null +++ b/app/javascript/controllers/bridge/buttons_controller.js @@ -0,0 +1,43 @@ +import { BridgeComponent } from "@hotwired/hotwire-native-bridge" +import { BridgeElement } from "@hotwired/hotwire-native-bridge" + +export default class extends BridgeComponent { + static component = "buttons" + static targets = [ "button" ] + + buttonTargetConnected() { + this.notifyBridgeOfConnect() + } + + buttonTargetDisconnected() { + if (!this.#isControllerTearingDown()) { + this.notifyBridgeOfConnect() + } + } + + notifyBridgeOfConnect() { + const buttons = this.#enabledButtonTargets + .map((target, index) => { + const element = new BridgeElement(target) + return { ...element.getButton(), index } + }) + + this.send("connect", { buttons }, message => { + this.#clickButton(message) + }) + } + + #clickButton(message) { + const selectedIndex = message.data.selectedIndex + this.#enabledButtonTargets[selectedIndex].click() + } + + get #enabledButtonTargets() { + return this.buttonTargets + .filter(target => !target.closest("[data-bridge-disabled]")) + } + + #isControllerTearingDown() { + return !document.body.contains(this.element) + } +} diff --git a/app/javascript/controllers/bridge/form_controller.js b/app/javascript/controllers/bridge/form_controller.js new file mode 100644 index 0000000000..a3c780e62a --- /dev/null +++ b/app/javascript/controllers/bridge/form_controller.js @@ -0,0 +1,62 @@ +import { BridgeComponent } from "@hotwired/hotwire-native-bridge" +import { BridgeElement } from "@hotwired/hotwire-native-bridge" + +export default class extends BridgeComponent { + static component = "form" + static targets = [ "submit", "cancel" ] + static values = { submitTitle: String } + + submitTargetConnected() { + this.notifyBridgeOfConnect() + this.#observeSubmitTarget() + } + + submitTargetDisconnected() { + this.notifyBridgeOfDisonnect() + this.submitObserver?.disconnect() + } + + notifyBridgeOfConnect() { + const submitElement = new BridgeElement(this.submitTarget) + const cancelElement = this.hasCancelTarget ? new BridgeElement(this.cancelTarget) : null + + const submitButton = { title: submitElement.title } + const cancelButton = cancelElement ? { title: cancelElement.title } : null + + this.send("connect", { submitButton, cancelButton }, message => this.receive(message)) + } + + receive(message) { + switch (message.event) { + case "submit": + this.submitTarget.click() + break + case "cancel": + this.cancelTarget.click() + break + } + } + + notifyBridgeOfDisonnect() { + this.send("disconnect") + } + + submitStart() { + this.send("submitStart") + } + + submitEnd() { + this.send("submitEnd") + } + + #observeSubmitTarget() { + this.submitObserver = new MutationObserver(() => { + this.send(this.submitTarget.disabled ? "submitDisabled" : "submitEnabled") + }) + + this.submitObserver.observe(this.submitTarget, { + attributes: true, + attributeFilter: [ "disabled" ] + }) + } +} diff --git a/app/javascript/controllers/bridge/insets_controller.js b/app/javascript/controllers/bridge/insets_controller.js new file mode 100644 index 0000000000..1cef156906 --- /dev/null +++ b/app/javascript/controllers/bridge/insets_controller.js @@ -0,0 +1,31 @@ +import { BridgeComponent } from "@hotwired/hotwire-native-bridge" + +// Bridge component to control custom safe-area insets from native apps. +// Sets CSS variables --injected-safe-inset-(top|right|bottom|left). +export default class extends BridgeComponent { + static component = "insets" + + connect() { + super.connect() + this.notifyBridgeOfConnect() + } + + disconnect() { + super.disconnect() + this.send("disconnect") + } + + notifyBridgeOfConnect() { + this.send("connect", {}, message => { + this.#setInsets(message.data) + }) + } + + #setInsets({ top, right, bottom, left }) { + const root = document.documentElement.style + root.setProperty("--injected-safe-inset-top", `${top}px`) + root.setProperty("--injected-safe-inset-right", `${right}px`) + root.setProperty("--injected-safe-inset-bottom", `${bottom}px`) + root.setProperty("--injected-safe-inset-left", `${left}px`) + } +} diff --git a/app/javascript/controllers/bridge/overflow_menu_controller.js b/app/javascript/controllers/bridge/overflow_menu_controller.js new file mode 100644 index 0000000000..ba2045dc61 --- /dev/null +++ b/app/javascript/controllers/bridge/overflow_menu_controller.js @@ -0,0 +1,43 @@ +import { BridgeComponent } from "@hotwired/hotwire-native-bridge" +import { BridgeElement } from "@hotwired/hotwire-native-bridge" + +export default class extends BridgeComponent { + static component = "overflow-menu" + static targets = [ "item" ] + + itemTargetConnected() { + this.notifyBridgeOfConnect() + } + + itemTargetDisconnected() { + if (!this.#isControllerTearingDown) { + this.notifyBridgeOfConnect() + } + } + + notifyBridgeOfConnect() { + const items = this.#enabledItemTargets + .map((target, index) => { + const element = new BridgeElement(target) + return { title: element.title, index } + }) + + this.send("connect", { items }, message => { + this.#clickItem(message) + }) + } + + #clickItem(message) { + const selectedIndex = message.data.selectedIndex + this.#enabledItemTargets[selectedIndex].click() + } + + get #enabledItemTargets() { + return this.itemTargets + .filter(target => !target.closest("[data-bridge-disabled]")) + } + + #isControllerTearingDown() { + return !document.body.contains(this.element) + } +} diff --git a/app/javascript/controllers/bridge/text_size_controller.js b/app/javascript/controllers/bridge/text_size_controller.js new file mode 100644 index 0000000000..a464b8d45b --- /dev/null +++ b/app/javascript/controllers/bridge/text_size_controller.js @@ -0,0 +1,25 @@ +import { BridgeComponent } from "@hotwired/hotwire-native-bridge" + +export default class extends BridgeComponent { + static component = "text-size" + + connect() { + super.connect() + this.notifyBridgeOfConnect() + } + + disconnect() { + super.disconnect() + this.send("disconnect") + } + + notifyBridgeOfConnect() { + this.send("connect", {}, message => { + this.#setTextSize(message.data) + }) + } + + #setTextSize(data) { + document.documentElement.dataset.textSize = data.textSize + } +} diff --git a/app/javascript/controllers/bridge/title_controller.js b/app/javascript/controllers/bridge/title_controller.js new file mode 100644 index 0000000000..f2226be350 --- /dev/null +++ b/app/javascript/controllers/bridge/title_controller.js @@ -0,0 +1,63 @@ +import { BridgeComponent } from "@hotwired/hotwire-native-bridge" +import { viewport } from "helpers/bridge/viewport_helpers" +import { nextFrame } from "helpers/timing_helpers" + +export default class extends BridgeComponent { + static component = "title" + static targets = [ "header" ] + static values = { title: String } + + async connect() { + super.connect() + await nextFrame() + this.#startObserver() + window.addEventListener("resize", this.#windowResized) + } + + disconnect() { + super.disconnect() + this.#stopObserver() + window.removeEventListener("resize", this.#windowResized) + } + + notifyBridgeOfVisibilityChange(visible) { + this.send("visibility", { title: this.#title, elementVisible: visible }) + } + + // Intersection Observer + + #startObserver() { + if (!this.hasHeaderTarget) return + + this.observer = new IntersectionObserver(([ entry ]) => + this.notifyBridgeOfVisibilityChange(entry.isIntersecting), + { rootMargin: `-${this.#topOffset}px 0px 0px 0px` } + ) + + this.observer.observe(this.headerTarget) + this.previousTopOffset = this.#topOffset + } + + #stopObserver() { + this.observer?.disconnect() + } + + #updateObserverIfNeeded() { + if (this.#topOffset === this.previousTopOffset) return + + this.#stopObserver() + this.#startObserver() + } + + #windowResized = () => { + this.#updateObserverIfNeeded() + } + + get #title() { + return this.titleValue ? this.titleValue : document.title + } + + get #topOffset() { + return viewport.top + } +} diff --git a/app/javascript/controllers/dialog_controller.js b/app/javascript/controllers/dialog_controller.js index f6449cea7b..037c5ec7b5 100644 --- a/app/javascript/controllers/dialog_controller.js +++ b/app/javascript/controllers/dialog_controller.js @@ -1,8 +1,9 @@ import { Controller } from "@hotwired/stimulus" import { orient } from "helpers/orientation_helpers" +import { isTouchDevice } from "helpers/platform_helpers" export default class extends Controller { - static targets = [ "dialog" ] + static targets = [ "dialog", "focusMouse", "focusTouch" ] static values = { modal: { type: Boolean, default: false }, sizing: { type: Boolean, default: true }, @@ -14,6 +15,10 @@ export default class extends Controller { if (this.autoOpenValue) this.open() } + focusTouchTargetConnected() { + this.#setupFocus() + } + open() { const modal = this.modalValue @@ -63,4 +68,10 @@ export default class extends Controller { captureKey(event) { if (event.key !== "Escape") { event.stopPropagation() } } + + #setupFocus() { + const touch = isTouchDevice() + if (this.hasFocusMouseTarget) this.focusMouseTarget.autofocus = !touch + if (this.hasFocusTouchTarget) this.focusTouchTarget.autofocus = touch + } } diff --git a/app/javascript/helpers/bridge/viewport_helpers.js b/app/javascript/helpers/bridge/viewport_helpers.js new file mode 100644 index 0000000000..559143607e --- /dev/null +++ b/app/javascript/helpers/bridge/viewport_helpers.js @@ -0,0 +1,24 @@ +let top = 0 +const viewportTarget = window.visualViewport || window + +export const viewport = { + get top() { + return top + }, + get height() { + return viewportTarget.height || window.innerHeight + } +} + +function update() { + requestAnimationFrame(() => { + const styles = getComputedStyle(document.documentElement) + const customInset = styles.getPropertyValue("--custom-safe-inset-top") + const fallbackInset = styles.getPropertyValue("--safe-area-inset-top") + const insetValue = (customInset || fallbackInset).trim() + top = parseInt(insetValue || "0", 10) || 0 + }) +} + +viewportTarget.addEventListener("resize", update) +update() diff --git a/app/javascript/initializers/bridge/bridge_element.js b/app/javascript/initializers/bridge/bridge_element.js new file mode 100644 index 0000000000..7ddeb1b335 --- /dev/null +++ b/app/javascript/initializers/bridge/bridge_element.js @@ -0,0 +1,18 @@ +import { BridgeElement } from "@hotwired/hotwire-native-bridge" + +BridgeElement.prototype.getButton = function() { + return { + title: this.title, + icon: this.getIcon() + } +} + +BridgeElement.prototype.getIcon = function() { + const url = this.bridgeAttribute(`icon-url`) + + if (url) { + return { url } + } + + return null +} diff --git a/app/javascript/initializers/index.js b/app/javascript/initializers/index.js index 10fb369755..90ef36a26a 100644 --- a/app/javascript/initializers/index.js +++ b/app/javascript/initializers/index.js @@ -1 +1,2 @@ import "initializers/current" +import "initializers/bridge/bridge_element" diff --git a/app/views/account/settings/show.html.erb b/app/views/account/settings/show.html.erb index 74e2744148..4385606357 100644 --- a/app/views/account/settings/show.html.erb +++ b/app/views/account/settings/show.html.erb @@ -1,7 +1,7 @@ <% @page_title = "Account Settings" %> <% content_for :header do %> -