Skip to content

Commit 98286d3

Browse files
authored
Merge pull request #269 from joyofrails/feat/combobox-a11y
Improve combobox a11y
2 parents d122dfc + 87711f2 commit 98286d3

32 files changed

+447
-152
lines changed
4.16 KB
Loading
Lines changed: 29 additions & 0 deletions
Loading

app/assets/images/app-icons/icon.svg

Lines changed: 3 additions & 3 deletions
Loading
10.6 KB
Loading
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
title: Joy of Rails 💜 Hatchbox
3+
author: Ross Kaffenberger
4+
layout: article
5+
summary: Joy of Rails deployments and server processes are managed by Hatchbox. Hatchbox is a Rails deployment tool that makes it easy to deploy Rails apps to your own servers on DigitalOcean, Linode, or similar.
6+
published: '2024-11-11'
7+
uuid: 0192f76f-2f11-7db3-9d72-0fa782be2543
8+
image: content/hatchbox/placeholder.jpg
9+
meta_image: content/hatchbox/placeholder.jpg
10+
tags:
11+
- Rails
12+
---
13+
14+
### Enabling jemalloc
15+
16+
https://hatchbox.relationkit.io/articles/122-how-can-i-use-jemalloc-with-my-application
Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,35 @@
1+
require "debug"
2+
13
class SearchesController < ApplicationController
24
rescue_from Searches::ParseFailed do |error|
35
respond_to do |format|
4-
format.html { render Searches::ShowView.new(pages: [], query: @raw_query) }
5-
format.turbo_stream { render turbo_stream: turbo_stream.replace("search-results", plain: "No results") }
6+
format.html { render Searches::ShowView.new(pages: [], query: params[:query]) }
7+
format.turbo_stream {
8+
render turbo_stream: [
9+
turbo_stream.replace("search-listbox", Searches::Listbox.new(pages: [], query: params[:query]))
10+
]
11+
}
612
end
713
end
814

915
def show
10-
@raw_query = params[:query] || ""
11-
pages = if @raw_query.present?
12-
query = Searches::Query.parse!(@raw_query)
16+
raw_query = params[:query] || ""
17+
pages = if raw_query.present?
18+
query = Searches::Query.parse!(raw_query)
1319
Page.search("#{query}*").with_snippets.ranked.limit(3)
1420
else
1521
[]
1622
end
1723

18-
render Searches::ShowView.new(pages:, query: @raw_query)
24+
respond_to do |format|
25+
format.html {
26+
render Searches::ShowView.new(pages:, query: raw_query)
27+
}
28+
format.turbo_stream {
29+
render turbo_stream: [
30+
turbo_stream.replace("search-listbox", Searches::Listbox.new(pages: pages, query: raw_query))
31+
]
32+
}
33+
end
1934
end
2035
end

app/helpers/application_helper.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
module ApplicationHelper
22
def seo_meta_tags
33
favicon_path = Rails.env.local? ? asset_path("app-icons/favicon-local.ico") : asset_path("app-icons/favicon.ico")
4+
favicon_svg_path = Rails.env.local? ? asset_path("app-icons/icon-local.svg") : asset_path("app-icons/icon.svg")
5+
apple_touch_icon_path = Rails.env.local? ? asset_path("app-icons/apple-touch-icon-local.png") : asset_path("app-icons/apple-touch-icon.png")
46
set_meta_tags icon: [
57
{rel: "icon", href: favicon_path, sizes: "32x32"},
6-
{rel: "icon", href: asset_path("app-icons/icon.svg"), type: "image/svg+xml"},
7-
{rel: "apple-touch-icon", href: asset_path("app-icons/apple-touch-icon.png")}
8+
{rel: "icon", href: favicon_svg_path, type: "image/svg+xml"},
9+
{rel: "apple-touch-icon", href: apple_touch_icon_path}
810
]
911
set_meta_tags index: true
1012
set_meta_tags og: {

app/javascript/controllers/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import SnippetScreenshot from './snippets/screenshot';
2828
import SnippetTweet from './snippets/tweet';
2929

3030
import SearchCombobox from './searches/combobox';
31+
import SearchListbox from './searches/listbox';
3132

3233
application.register('modal-opener', ModalOpener);
3334
application.register('analytics', AnalyticsCustomEvent);
@@ -51,3 +52,4 @@ application.register('snippet-screenshot', SnippetScreenshot);
5152
application.register('snippet-tweet', SnippetTweet);
5253

5354
application.register('search-combobox', SearchCombobox);
55+
application.register('search-listbox', SearchListbox);

app/javascript/controllers/searches/combobox.js

Lines changed: 65 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,81 @@ const cancel = (event) => {
99
event.preventDefault();
1010
};
1111

12+
/* When the index is out of bounds, return the first or last item */
13+
function cyclingValueAt(array, index) {
14+
const first = 0;
15+
const last = array.length - 1;
16+
17+
if (index < first) return array[last];
18+
if (index > last) return array[first];
19+
return array[index];
20+
}
21+
1222
export default class extends Controller {
1323
connect() {
1424
console.log('Connected');
15-
16-
this.selectIndex(0);
1725
}
1826

1927
selectIndex(index) {
20-
return this.items.forEach((item, i) => {
21-
item.classList.toggle('selected', i === index);
22-
});
28+
if (this.options.length === 0) return;
29+
30+
console.log('Selecting index', index);
31+
32+
const option = cyclingValueAt(this.options, index);
33+
34+
this.select(option);
35+
}
36+
37+
select(option) {
38+
console.log('Selecting', option.id);
39+
40+
this.options.forEach(this.deselect.bind(this));
41+
42+
option.classList.add('selected');
43+
option.setAttribute('aria-selected', 'true');
44+
this.setActiveDescendant(option.id);
45+
}
46+
47+
deselect(option) {
48+
option.classList.remove('selected');
49+
option.removeAttribute('aria-selected');
50+
this.setActiveDescendant('');
51+
}
52+
53+
setActiveDescendant(id) {
54+
console.log('Setting active descendant', id);
55+
this.combobox.setAttribute('aria-activedescendant', id);
56+
}
57+
58+
listboxOpen({ detail }) {
59+
console.log('Listbox open', this.options.length > 0);
60+
this.combobox.setAttribute('aria-expanded', this.options.length > 0);
2361
}
2462

2563
navigate(event) {
26-
console.log('Navigating', event); // event.key, e.g. "ArrowDown"
2764
this.navigationKeyHandlers[event.key]?.call(this, event);
2865
}
2966

67+
get options() {
68+
return [...this.element.querySelectorAll('[role="option"]')];
69+
}
70+
71+
get selectedItem() {
72+
return this.element.querySelector('[role="option"].selected');
73+
}
74+
75+
get selectedItemIndex() {
76+
return this.options.indexOf(this.selectedItem);
77+
}
78+
79+
get combobox() {
80+
return this.element.querySelector('[role="combobox"]');
81+
}
82+
83+
get listbox() {
84+
return this.element.querySelector('[role="listbox"]');
85+
}
86+
3087
get navigationKeyHandlers() {
3188
return {
3289
ArrowDown: (event) => {
@@ -42,25 +99,13 @@ export default class extends Controller {
4299
cancel(event);
43100
},
44101
End: (event) => {
45-
this.selectIndex(this.items.length - 1);
102+
this.selectIndex(this.options.length - 1);
46103
cancel(event);
47104
},
48105
Enter: (event) => {
49-
this.selectedItem.click();
106+
this.selectedItem?.querySelector('a')?.click();
50107
cancel(event);
51108
},
52109
};
53110
}
54-
55-
get items() {
56-
return [...this.element.querySelectorAll('a')];
57-
}
58-
59-
get selectedItem() {
60-
return this.element.querySelector('.selected');
61-
}
62-
63-
get selectedItemIndex() {
64-
return this.items.indexOf(this.selectedItem);
65-
}
66111
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Controller } from '@hotwired/stimulus';
2+
3+
import { debug } from '../../utils';
4+
5+
const console = debug('app:javascript:controllers:searches:listbox');
6+
7+
export default class extends Controller {
8+
connect() {
9+
console.log('Connected');
10+
11+
this.dispatch('connected', { detail: { controller: this } });
12+
}
13+
}

0 commit comments

Comments
 (0)