Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## [Unreleased]

### Changed

- The popover now uses the Dialog API behind the scenes

## [0.7.2] - 2025-07-04

### Changed
Expand Down
8 changes: 4 additions & 4 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,11 @@ GEM
msgpack (~> 1.2)
builder (3.2.4)
byebug (11.1.3)
capybara (3.39.2)
capybara (3.40.0)
addressable
matrix
mini_mime (>= 0.1.3)
nokogiri (~> 1.8)
nokogiri (~> 1.11)
rack (>= 1.6.0)
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
Expand Down Expand Up @@ -104,7 +104,7 @@ GEM
yard (~> 0.9.25)
zeitwerk (~> 2.5)
marcel (1.0.2)
matrix (0.4.2)
matrix (0.4.3)
method_source (1.0.0)
mini_mime (1.1.5)
minitest (5.25.5)
Expand Down Expand Up @@ -243,7 +243,7 @@ DEPENDENCIES
appraisal (~> 2.5)
bootsnap
byebug (~> 11.1, >= 11.1.3)
capybara (~> 3.39, >= 3.39.2)
capybara (~> 3.40)
impulse_view_components!
lookbook (~> 2.0, >= 2.0.5)
puma (~> 6.4, >= 6.4.3)
Expand Down
4 changes: 2 additions & 2 deletions app/components/impulse/popover_component.html.erb
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<%= render(Impulse::BaseRenderer.new(**@system_args)) do %>
<%= trigger %>

<div id="<%= @panel_id %>" popover="manual" class="awc-popover-container bs-popover-auto" tabindex="-1" data-target="awc-popover.panel" data-action="toggle->awc-popover#handleToggle">
<dialog id="<%= @panel_id %>" class="awc-popover-container bs-popover-auto" data-target="awc-popover.panel" data-action="close->awc-popover#hide">
<div class="arrow" data-target="awc-popover.arrow"></div>
<%= header %>
<%= body %>
</div>
</dialog>
<% end %>
5 changes: 2 additions & 3 deletions app/components/impulse/popover_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,14 @@ class PopoverComponent < ApplicationComponent
system_args[:tag] = :button
system_args[:type] = system_args.fetch(:type, "button")
system_args[:role] = :button
system_args[:"aria-haspopup"] = :dialog
system_args[:"aria-expanded"] = false
system_args[:"aria-controls"] = @panel_id
system_args[:popovertarget] = @panel_id
system_args[:"aria-disabled"] = (!!system_args[:disabled]).to_s

system_args[:data] = merge_attributes(
system_args[:data],
target: "awc-popover.button"
target: "awc-popover.button",
action: "click->awc-popover#handleToggle"
)

Impulse::BaseRenderer.new(**system_args)
Expand Down
2 changes: 1 addition & 1 deletion docs/js-api/popover.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ popover.show();
Hides the popover.

```js
popover.hide();
await popover.hide();
```

### `toggle`
Expand Down
8 changes: 4 additions & 4 deletions gemfiles/rails_6_1.gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,11 @@ GEM
msgpack (~> 1.2)
builder (3.2.4)
byebug (11.1.3)
capybara (3.39.2)
capybara (3.40.0)
addressable
matrix
mini_mime (>= 0.1.3)
nokogiri (~> 1.8)
nokogiri (~> 1.11)
rack (>= 1.6.0)
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
Expand Down Expand Up @@ -126,7 +126,7 @@ GEM
net-pop
net-smtp
marcel (1.0.2)
matrix (0.4.2)
matrix (0.4.3)
method_source (1.0.0)
mini_mime (1.1.5)
minitest (5.20.0)
Expand Down Expand Up @@ -275,7 +275,7 @@ DEPENDENCIES
appraisal (~> 2.5)
bootsnap
byebug (~> 11.1, >= 11.1.3)
capybara (~> 3.39, >= 3.39.2)
capybara (~> 3.40)
impulse_view_components!
lookbook (~> 2.0, >= 2.0.5)
puma (~> 6.4, >= 6.4.3)
Expand Down
8 changes: 4 additions & 4 deletions gemfiles/rails_7.gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,11 @@ GEM
msgpack (~> 1.2)
builder (3.2.4)
byebug (11.1.3)
capybara (3.39.2)
capybara (3.40.0)
addressable
matrix
mini_mime (>= 0.1.3)
nokogiri (~> 1.8)
nokogiri (~> 1.11)
rack (>= 1.6.0)
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
Expand Down Expand Up @@ -132,7 +132,7 @@ GEM
net-pop
net-smtp
marcel (1.0.2)
matrix (0.4.2)
matrix (0.4.3)
method_source (1.0.0)
mini_mime (1.1.5)
minitest (5.20.0)
Expand Down Expand Up @@ -281,7 +281,7 @@ DEPENDENCIES
appraisal (~> 2.5)
bootsnap
byebug (~> 11.1, >= 11.1.3)
capybara (~> 3.39, >= 3.39.2)
capybara (~> 3.40)
impulse_view_components!
lookbook (~> 2.0, >= 2.0.5)
puma (~> 6.4, >= 6.4.3)
Expand Down
8 changes: 4 additions & 4 deletions gemfiles/rails_7_1.gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,11 @@ GEM
msgpack (~> 1.2)
builder (3.2.4)
byebug (11.1.3)
capybara (3.39.2)
capybara (3.40.0)
addressable
matrix
mini_mime (>= 0.1.3)
nokogiri (~> 1.8)
nokogiri (~> 1.11)
rack (>= 1.6.0)
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
Expand Down Expand Up @@ -150,7 +150,7 @@ GEM
net-pop
net-smtp
marcel (1.0.2)
matrix (0.4.2)
matrix (0.4.3)
method_source (1.0.0)
mini_mime (1.1.5)
minitest (5.20.0)
Expand Down Expand Up @@ -315,7 +315,7 @@ DEPENDENCIES
appraisal (~> 2.5)
bootsnap
byebug (~> 11.1, >= 11.1.3)
capybara (~> 3.39, >= 3.39.2)
capybara (~> 3.40)
impulse_view_components!
lookbook (~> 2.0, >= 2.0.5)
puma (~> 6.4, >= 6.4.3)
Expand Down
2 changes: 1 addition & 1 deletion impulse_view_components.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Gem::Specification.new do |spec|
spec.add_runtime_dependency "view_component", "~> 3.11"
spec.add_runtime_dependency "actionview", ">= 6.1.0"

spec.add_development_dependency "capybara", "~> 3.39", ">= 3.39.2"
spec.add_development_dependency "capybara", "~> 3.40"
spec.add_development_dependency "byebug", "~> 11.1", ">= 11.1.3"
spec.add_development_dependency "webmock", "~> 3.18", ">= 3.18.1"
end
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@
"@ambiki/combobox": "^2.0.1",
"@ambiki/impulse": "^1.1.0",
"@floating-ui/dom": "^1.6.3",
"@oddbird/popover-polyfill": "^0.4.4",
"tabbable": "^6.2.0"
},
"devDependencies": {
Expand Down
88 changes: 49 additions & 39 deletions src/elements/popover/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { ImpulseElement, property, registerElement, target } from '@ambiki/impulse';
import type { Placement, Strategy } from '@floating-ui/dom';
import '@oddbird/popover-polyfill';
import { isLooselyFocusable } from 'src/helpers/focus';
import useFloatingUI, { UseFloatingUIType } from 'src/hooks/use_floating_ui';
import useOutsideClick from 'src/hooks/use_outside_click';
import { stripCSSUnit } from '../../helpers/string';

const popovers = new Set<AwcPopoverElement>();

@registerElement('awc-popover')
export default class AwcPopoverElement extends ImpulseElement {
/**
* When the popover is open or not. To make the popover open by default, set it to `true`.
* Make sure you aren't opening multiple popovers on page load, as this may degrade the user experience.
*/
@property({ type: Boolean }) open = false;

/**
* The placement of the popover. The actual placement will vary to keep the popover inside the viewport.
* @see https://floating-ui.com/docs/computePosition#placement for a comprehensive list of all available placements.
Expand All @@ -28,11 +30,14 @@ export default class AwcPopoverElement extends ImpulseElement {
@property({ type: Array }) clickBoundaries: string[] = [];

@target() button: HTMLButtonElement;
@target() panel: HTMLElement;
@target() panel: HTMLDialogElement;
@target() arrow: HTMLElement;

private floatingUI: UseFloatingUIType;

/**
* Called when the element is added to the DOM.
*/
connected() {
this.floatingUI = useFloatingUI(this, {
referenceElement: this.button,
Expand All @@ -49,53 +54,66 @@ export default class AwcPopoverElement extends ImpulseElement {

useOutsideClick(this, {
boundaries: this.boundaries,
callback: (event: Event, target: HTMLElement) => {
callback: () => {
// Only proceed if element is open and nested popovers are hidden.
if (this.open && !this.hasNestedOpenPopovers) {
this.hide();
// Prevent modals from closing accidentally.
if (!isLooselyFocusable(target)) {
event.preventDefault();
this.button.focus();
}
}
},
});

if (this.open) {
this.show();
}
}

/**
* Called when the element is removed from the DOM.
*/
disconnected() {
this.hide();
}

async handleToggle() {
if (this.open) {
popovers.add(this);
this.closeOtherPopovers();
this.emit('show');
this.button.setAttribute('aria-expanded', 'true');
this.floatingUI.start();
this.emit('shown');
} else {
this.emit('hide');
this.button.setAttribute('aria-expanded', 'false');
await this.floatingUI.stop();
popovers.delete(this);
await this.hide();
this.emit('hidden');
} else {
this.emit('show');
this.show();
this.emit('shown');
}
}

/**
* Shows/hides the popover.
*/
toggle() {
this.panel.togglePopover();
}

/**
* Shows the popover.
*/
show() {
if (this.open) return;
this.panel.showPopover();
this.closeOtherPopovers();
this.open = true;
this.panel.show();
this.button.setAttribute('aria-expanded', 'true');
this.floatingUI.start();
}

hide(event?: Event) {
/**
* Hides a popover.
*/
async hide(event?: Event): Promise<void> {
if (!this.open) return;
this.panel.hidePopover();
this.open = false;
this.panel.close();
await this.floatingUI.stop();
this.button.setAttribute('aria-expanded', 'false');

// This event originated from a button click.
if (event) {
Expand All @@ -121,22 +139,23 @@ export default class AwcPopoverElement extends ImpulseElement {
}
}

async reposition() {
/**
* Repositions the popover.
*/
async reposition(): Promise<void> {
await this.floatingUI.update();
}

private closeOtherPopovers() {
const popovers = Array.from(document.querySelectorAll<AwcPopoverElement>(this.identifier));
for (const popover of popovers) {
if (popover === this || popover.contains(this)) continue;
popover.hide();
}
}

private get hasNestedOpenPopovers() {
const popover = [...popovers].find((p) => p === this);
if (!popover) return false;
const childPopovers = Array.from(popover.querySelectorAll<AwcPopoverElement>(this.identifier));
return childPopovers.some((childPopover) => popovers.has(childPopover));
private get hasNestedOpenPopovers(): boolean {
return Array.from(this.querySelectorAll<AwcPopoverElement>(this.identifier)).some((popover) => popover.open);
}

private get arrowPadding() {
Expand All @@ -148,15 +167,6 @@ export default class AwcPopoverElement extends ImpulseElement {
const elements = this.clickBoundaries.map((s) => document.querySelector<HTMLElement>(s));
return elements.concat([this.button, this.panel]);
}

get open(): boolean {
if (!this.panel) return false;
try {
return this.panel.matches(':popover-open');
} catch {
return this.panel.matches('.\\:popover-open');
}
}
}

declare global {
Expand Down
4 changes: 0 additions & 4 deletions test/components/popover_component_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,15 @@ class PopoverComponentTest < ApplicationTest
refute_selector ".popover-header"

assert_selector "[data-test-id='btn']", text: "Toggle popover"
assert_selector "[data-test-id='btn'][aria-haspopup='dialog']"
assert_selector "[data-test-id='btn'][aria-expanded='false']"
assert_selector "[data-test-id='btn'][role='button']"
assert_selector "[data-test-id='btn'][type='button']"

assert_selector ".popover-body", text: "Popover body"

id = page.find("[data-test-id='btn']")["aria-controls"]
assert_selector "button[popovertarget='#{id}']"

assert_selector ".awc-popover-container[id='#{id}']"
assert_selector ".awc-popover-container[tabindex='-1']"
assert_selector ".awc-popover-container[popover='manual']"
# Arrow
assert_selector ".arrow"
end
Expand Down
Loading