Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add New Combobox Component #66

Merged
merged 10 commits into from
Aug 4, 2024
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: 2 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ GEM
tailwind_merge (0.12.0)
lru_redux (~> 1.1)
unicode-display_width (2.5.0)
zeitwerk (2.6.16)
zeitwerk (2.6.17)

PLATFORMS
arm64-darwin-23
Expand All @@ -74,4 +74,4 @@ DEPENDENCIES
standard

BUNDLED WITH
2.5.10
2.3.25
12 changes: 10 additions & 2 deletions lib/phlex_ui.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@
require "phlex"
require "zeitwerk"

loader = Zeitwerk::Loader.for_gem
# WARNING: Zeitwerk defines the constant RBUI after the directory
# loader = Zeitwerk::Loader.for_gem
# temporarily disable the constant to avoid errors with dual module definition
loader = Zeitwerk::Loader.for_gem(warn_on_extra_files: false)

loader.inflector.inflect(
"phlex_ui" => "PhlexUI"
"phlex_ui" => "PhlexUI",
"rbui" => "RBUI"
)

loader.collapse("#{__dir__}/phlex_ui/accordion")
Expand Down Expand Up @@ -42,6 +47,9 @@
loader.collapse("#{__dir__}/phlex_ui/tooltip")
loader.collapse("#{__dir__}/phlex_ui/typography")

# RBUI
loader.collapse("#{__dir__}/rbui/combobox")

loader.setup # ready!

module PhlexUI
Expand Down
5 changes: 5 additions & 0 deletions lib/rbui.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# frozen_string_literal: true

module RBUI
extend Phlex::Kit
end
64 changes: 64 additions & 0 deletions lib/rbui/attribute_merger.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
module RBUI
class AttributeMerger
attr_reader :default_attrs, :user_attrs

def initialize(default_attrs, user_attrs)
@default_attrs = flatten_hash(default_attrs)
@user_attrs = flatten_hash(user_attrs)
end

# @return [String]
# any key that ends with ! will override the default value
# ex: if default_attrs = { class: "text-right" }, user_attrs = { class!: "text-left" }
# the result will be { class: "text-left }
def call
merged_attrs = merge_hashes(default_attrs, user_attrs)
mix(merged_attrs, user_attrs)
end

private

# @return [Hash]
def mix(*args)
args.each_with_object({}) do |object, result|
result.merge!(object) do |_key, old, new|
case new
when Hash
old.is_a?(Hash) ? mix(old, new) : new
when Array
old.is_a?(Array) ? (old + new) : new
when String
old.is_a?(String) ? "#{old} #{new}" : new
else
new
end
end

result.transform_keys! do |key|
key.end_with?("!") ? key.name.chop.to_sym : key
end
end
end

def flatten_hash(hash, parent_key = "", result_hash = {})
hash.each do |key, value|
new_key = parent_key.empty? ? key : :"#{parent_key}_#{key}"
if value.is_a? Hash
flatten_hash(value, new_key, result_hash)
else
result_hash[new_key] = value
end
end
result_hash
end

def merge_hashes(hash1, hash2)
flat_hash1 = flatten_hash(hash1)
flat_hash2 = flatten_hash(hash2)

flat_hash1.merge(flat_hash2) do |key, oldval, newval|
"#{oldval} #{newval}"
end
end
end
end
27 changes: 27 additions & 0 deletions lib/rbui/base.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# frozen_string_literal: true

require "tailwind_merge"

module RBUI
class Base < Phlex::HTML
attr_reader :attrs

def initialize(**user_attrs)
@attrs = AttributeMerger.new(default_attrs, user_attrs).call
@attrs[:class] = ::TailwindMerge::Merger.new.merge(@attrs[:class]) if @attrs[:class]
end

if defined?(Rails) && Rails.env.development?
def before_template
comment { "Before #{self.class.name}" }
super
end
end

private

def default_attrs
{}
end
end
end
15 changes: 15 additions & 0 deletions lib/rbui/combobox/combobox.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# frozen_string_literal: true

module RBUI
class Combobox < Base
def view_template(&)
div(**attrs, &)
end

private

def default_attrs
{data: {controller: "rbui--combobox", action: "click@window->rbui--combobox#clickOutside", rbui__combobox_closed_value: "true"}}
end
end
end
23 changes: 23 additions & 0 deletions lib/rbui/combobox/combobox_content.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# frozen_string_literal: true

module RBUI
class ComboboxContent < Base
def view_template(&)
div(**attrs) do
div(
data: {controller: "rbui--combobox-content", action: "keydown.up->rbui--combobox-content#handleKeyUp keydown.down->rbui--combobox-content#handleKeyDown keydown.enter->rbui--combobox-content#handleKeyEnter keydown.esc->rbui--combobox-content#handleKeyEsc"},
class: "flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground rounded-lg border shadow-md", &
)
end
end

private

def default_attrs
{
data: {rbui__combobox_target: "popover"},
class: "invisible absolute top-0 left-0 p-1.5 rounded"
}
end
end
end
105 changes: 105 additions & 0 deletions lib/rbui/combobox/combobox_content_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { Controller } from "@hotwired/stimulus";

const POPOVER_OPENED = "rbui--combobox#popoverOpened";

export const ITEM_KEY_UP = "rbui--combobox-content#keyUp";
export const ITEM_KEY_DOWN = "rbui--combobox-content#keyDown";
export const ITEM_KEY_ENTER = "rbui--combobox-content#keyEnter";
export const ITEM_KEY_ESC = "rbui--combobox-content#keyEsc";

export default class extends Controller {
static targets = ["list", "item", "empty", "group", "search"];

connect() {
document.addEventListener(POPOVER_OPENED, (event) => this.handlePopoverToggle(event), false);
this.generateItemsIds();
}

disconnect() {
document.removeEventListener(POPOVER_OPENED, (event) => this.handlePopoverToggle(event), false);
}

handlePopoverToggle(event) {
const { closed } = event.detail;
this.searchTarget.value = "";
if (!closed) {
this.searchTarget.focus();
this.toggleVisibility(this.itemTargets, true);
this.toggleVisibility(this.groupTargets, true);
this.toggleVisibility(this.emptyTargets, false);
}
}

handleKeyUp() {
const id = this.getSelectedItemId();

const event = new CustomEvent(ITEM_KEY_UP, { detail: { id } });
document.dispatchEvent(event);
}

handleKeyDown() {
const id = this.getSelectedItemId();
const { length } = this.itemTargets;

const event = new CustomEvent(ITEM_KEY_DOWN, { detail: { id, length } });
document.dispatchEvent(event);
}

handleKeyEnter() {
const id = this.getSelectedItemId();

const event = new CustomEvent(ITEM_KEY_ENTER, { detail: { id } });
document.dispatchEvent(event);
}

handleKeyEsc() {
document.dispatchEvent(new CustomEvent(ITEM_KEY_ESC));
}

filter(event) {
const query = this.sanitizeStr(event.target.value);

this.toggleVisibility(this.itemTargets, false);

const visibleItems = this.filterItems(query);
this.toggleVisibility(visibleItems, true);

this.toggleVisibility(this.emptyTargets, visibleItems.length === 0);

this.updateGroupVisibility();
}

updateGroupVisibility() {
this.groupTargets.forEach((group) => {
const hasVisibleItems =
group.querySelectorAll("[data-rbui--combobox-content-target='item']:not(.hidden)").length > 0;
this.toggleVisibility([group], hasVisibleItems);
});
}

generateItemsIds() {
const listId = this.listTarget.getAttribute("id");
this.itemTargets.forEach((item, index) => {
if (index === 0) item.setAttribute("aria-selected", "true");

item.id = `${listId}-${index}`;
});
}

filterItems(query) {
return this.itemTargets.filter((item) => this.sanitizeStr(item.innerText).includes(query));
}

toggleVisibility(elements, isVisible) {
elements.forEach((el) => el.classList.toggle("hidden", !isVisible));
}

sanitizeStr(str) {
return str.toLowerCase().trim();
}

getSelectedItemId() {
const selectedItem = this.itemTargets.find((item) => item.getAttribute("aria-selected") === "true");
return selectedItem.getAttribute("id");
}
}
71 changes: 71 additions & 0 deletions lib/rbui/combobox/combobox_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { Controller } from "@hotwired/stimulus";
import { computePosition, autoUpdate } from "@floating-ui/dom";
import { ITEM_SELECTED } from "./combobox_item_controller";
import { ITEM_KEY_ESC } from "./combobox_content_controller";

export const POPOVER_OPENED = "rbui--combobox#popoverOpened";

export default class extends Controller {
static targets = ["input", "popover", "content", "search"];

static values = { closed: Boolean };

constructor(...args) {
super(...args);
this.cleanup = undefined;
}

connect() {
this.setFloatingElement();

document.addEventListener(ITEM_SELECTED, (e) => this.itemSelected(e.detail), false);
document.addEventListener(ITEM_KEY_ESC, () => this.toogleContent(), false);
}

disconnect() {
document.removeEventListener(ITEM_SELECTED, (e) => this.itemSelected(e.detail), false);
document.removeEventListener(ITEM_KEY_ESC, () => this.toogleContent(), false);
this.cleanup();
}

onClick() {
this.toogleContent();
}

clickOutside(event) {
if (this.closedValue) return;
if (this.element.contains(event.target)) return;

event.preventDefault();
this.toogleContent();
}

itemSelected({ value, label }) {
this.inputTarget.value = value;
this.contentTarget.innerText = label;
this.toogleContent();
}

toogleContent() {
this.closedValue = !this.closedValue;

this.popoverTarget.classList.toggle("invisible");
this.inputTarget.setAttribute("aria-expanded", !this.closedValue);

if (!this.closedValue) {
const event = new CustomEvent(POPOVER_OPENED, { detail: { closed: this.closedValue } });
document.dispatchEvent(event);
}
}

setFloatingElement() {
this.cleanup = autoUpdate(this.inputTarget, this.popoverTarget, () => {
computePosition(this.inputTarget, this.popoverTarget).then(({ x, y }) => {
Object.assign(this.popoverTarget.style, {
left: `${x}px`,
top: `${y}px`,
});
});
});
}
}
15 changes: 15 additions & 0 deletions lib/rbui/combobox/combobox_empty.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# frozen_string_literal: true

module RBUI
class ComboboxEmpty < Base
def view_template(&)
div(**attrs, &)
end

private

def default_attrs
{class: "hidden py-6 text-center text-sm", role: "presentation", data: {rbui__combobox_content_target: "empty"}}
end
end
end
Loading