Skip to content

Commit f99055d

Browse files
committed
add return type text filter + searchable dropdown component
1 parent cf88a2f commit f99055d

5 files changed

Lines changed: 331 additions & 0 deletions

File tree

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
---
2+
interface Props {
3+
isMobile?: boolean;
4+
options?: string[];
5+
}
6+
7+
const {
8+
isMobile,
9+
options
10+
} = Astro.props;
11+
12+
const suffix = isMobile ? '-mobile' : '';
13+
---
14+
15+
<script>
16+
// use a custom element to make init easy
17+
class SearchableDropdown extends HTMLElement {
18+
private input: HTMLInputElement;
19+
private list: HTMLElement;
20+
private noResults: HTMLElement;
21+
private items: HTMLElement[];
22+
23+
constructor() {
24+
super();
25+
this.input = this.querySelector('.dropdown-input')!;
26+
this.list = this.querySelector('.dropdown-list')!;
27+
this.noResults = this.querySelector('.no-results')!;
28+
this.items = Array.from(this.querySelectorAll('.dropdown-item'));
29+
}
30+
31+
// when element added
32+
connectedCallback() {
33+
this.input.addEventListener('input', (e) => this.handleInput(e));
34+
this.input.addEventListener('focus', () => this.showDropdown());
35+
36+
this.items.forEach(item => {
37+
item.addEventListener('click', () => this.selectItem(item.dataset.value!));
38+
});
39+
document.addEventListener('click', this.handleOutsideClick);
40+
}
41+
42+
// when element removed
43+
disconnectedCallback() {
44+
document.removeEventListener('click', this.handleOutsideClick);
45+
}
46+
47+
private handleOutsideClick = (e: MouseEvent) => {
48+
if (!this.contains(e.target as Node)) {
49+
this.hideDropdown();
50+
}
51+
}
52+
53+
private handleInput(e: Event) {
54+
const searchTerm = (e.target as HTMLInputElement).value.toLowerCase();
55+
let visibleCount = 0;
56+
57+
this.items.forEach(item => {
58+
const value = item.dataset.value?.toLowerCase() || '';
59+
const isMatch = value.includes(searchTerm);
60+
item.style.display = isMatch ? 'block' : 'none';
61+
if (isMatch) visibleCount++;
62+
});
63+
64+
const hasInput = searchTerm.length > 0;
65+
this.list.style.display = visibleCount > 0 ? 'block' : 'none';
66+
this.noResults.style.display = (visibleCount === 0 && hasInput) ? 'block' : 'none';
67+
}
68+
69+
private showDropdown() {
70+
const hasVisibleItems = this.items.some(item => item.style.display !== 'none');
71+
if (hasVisibleItems) this.list.style.display = 'block';
72+
}
73+
74+
private hideDropdown() {
75+
this.list.style.display = 'none';
76+
this.noResults.style.display = 'none';
77+
}
78+
79+
private selectItem(value: string) {
80+
this.input.value = value;
81+
this.hideDropdown();
82+
this.input.dispatchEvent(new Event('input', { bubbles: true }));
83+
}
84+
}
85+
86+
// Register the custom element
87+
customElements.define('searchable-dropdown', SearchableDropdown);
88+
</script>
89+
90+
<searchable-dropdown data-suffix={suffix}>
91+
<input
92+
type="text"
93+
id={`return-type-filter-input${suffix}`}
94+
placeholder="e.g., number, text, player"
95+
class="dropdown-input"
96+
autocomplete="off"
97+
/>
98+
99+
<ul class="dropdown-list" style="display: none;">
100+
{options?.map((option) => (
101+
<li class="dropdown-item" data-value={option}>
102+
{option}
103+
</li>
104+
))}
105+
</ul>
106+
107+
<div class="no-results" style="display: none;">
108+
No matches found
109+
</div>
110+
</div>
111+
112+
<style>
113+
searchable-dropdown {
114+
position: relative;
115+
width: 16rem;
116+
}
117+
118+
.dropdown-input {
119+
width: 100%;
120+
padding: 0.5rem 0.75rem;
121+
border: 1px solid #d1d5db;
122+
border-radius: 0.375rem;
123+
outline: none;
124+
}
125+
126+
.dropdown-input:focus {
127+
ring: 2px;
128+
ring-color: #3b82f6;
129+
border-color: #3b82f6;
130+
}
131+
132+
.dropdown-list {
133+
position: absolute;
134+
z-index: 10;
135+
width: 100%;
136+
margin-top: 0.25rem;
137+
background-color: white;
138+
border: 1px solid #d1d5db;
139+
border-radius: 0.375rem;
140+
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
141+
max-height: 15rem;
142+
overflow-y: auto;
143+
list-style: none;
144+
padding: 0;
145+
margin: 0.25rem 0 0 0;
146+
}
147+
148+
.dropdown-item {
149+
padding: 0.5rem 0.75rem;
150+
cursor: pointer;
151+
transition: background-color 0.15s;
152+
}
153+
154+
.dropdown-item:hover {
155+
background-color: #eff6ff;
156+
}
157+
158+
.no-results {
159+
position: absolute;
160+
z-index: 10;
161+
width: 100%;
162+
margin-top: 0.25rem;
163+
background-color: white;
164+
border: 1px solid #d1d5db;
165+
border-radius: 0.375rem;
166+
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
167+
padding: 0.75rem;
168+
color: #6b7280;
169+
font-size: 0.875rem;
170+
}
171+
</style>
172+

src/components/syntaxes/SyntaxCard.astro

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const { syntax, isNew }: Props = Astro.props;
1616
<section id=`card-${syntax.id}` class=`syntax-card ${syntax.type.toString().toLowerCase()}`
1717
data-syntax-type={syntax.type} data-syntax-id={syntax.id} data-syntax-name={syntax.name}
1818
data-syntax-since={syntax.since} data-syntax-keywords={syntax.keywords}
19+
data-syntax-returns={syntax.returns?.name}
1920
>
2021
<div class="col">
2122
<AnchorHeading level='3' id={syntax.id}>

src/components/syntaxes/SyntaxDisplayManager.astro

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ export interface VersionFilterData {
116116
let versionFilter: VersionFilterData = {
117117
versions: [], range: 'strict', type: 'any',
118118
};
119+
let returnTypeFilter: string | null = null;
119120

120121
let hasSearch = false;
121122
let searchCache: Map<string, number> = new Map<string, number>();
@@ -167,6 +168,19 @@ export interface VersionFilterData {
167168
});
168169
}
169170

171+
// return type filter
172+
if (returnTypeFilter) {
173+
const target = returnTypeFilter.toLowerCase();
174+
filtered = filtered.filter(item => {
175+
if (item.element.dataset.syntaxReturns) {
176+
const candidate = (item.element.dataset.syntaxReturns as string).toLowerCase();
177+
// todo: inheritance mapping?
178+
return candidate.includes(target);
179+
}
180+
return false;
181+
})
182+
}
183+
170184
if (hasSearch) { // need to filter and sort based on search cache
171185
return filtered
172186
.filter(item => searchCache.has(item.syntaxId))
@@ -531,6 +545,10 @@ export interface VersionFilterData {
531545
versionFilter = data;
532546
fullRender();
533547
},
548+
updateReturnTypeFilter: (type: string) => {
549+
returnTypeFilter = type;
550+
fullRender();
551+
},
534552
updateSearchQuery: (searchQuery: string = '') => {
535553
recomputeSearchCache(searchQuery);
536554
fullRender();

src/components/syntaxes/SyntaxFilter.astro

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
---
2+
import ReturnTypeFilter from './filters/ReturnTypeFilter.astro';
23
import TypeFilter from './filters/TypeFilter.astro'
34
import VersionFilter from './filters/VersionFilter.astro'
45
@@ -13,6 +14,7 @@ const { isMobile } = Astro.props;
1314
<h1>Filters</h1>
1415
<TypeFilter isMobile={isMobile} />
1516
<VersionFilter isMobile={isMobile} />
17+
<ReturnTypeFilter isMobile={isMobile} />
1618
</div>
1719

1820
<style>
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
---
2+
import SearchableDropdown from '../../SearchableDropdown.astro';
3+
import rawSyntaxes from "../../../assets/syntaxes.json"
4+
5+
interface Props {
6+
isMobile?: boolean;
7+
}
8+
const { isMobile } = Astro.props;
9+
const suffix = isMobile ? '-mobile' : '';
10+
11+
// Define the options for the return type filter
12+
const returnTypeOptions = rawSyntaxes.types.map((type: any) => type.name);
13+
---
14+
<script>
15+
import { getParam, setParam } from "../../../utils/utils";
16+
17+
let expectedType: string = '';
18+
let updateReturnTypeFilter: ((type: string) => void) | null = null;
19+
20+
function handleReturnTypeFilterChange(input: HTMLInputElement) {
21+
expectedType = input.value.trim();
22+
setParam('return_type', expectedType);
23+
24+
if (updateReturnTypeFilter) {
25+
updateReturnTypeFilter(expectedType);
26+
}
27+
}
28+
29+
function syncReturnTypeInput(regular: HTMLInputElement, mobile: HTMLInputElement) {
30+
regular.value = mobile.value = expectedType;
31+
}
32+
33+
function setupMirroredInputs() {
34+
const regular = document.getElementById('return-type-filter-input');
35+
const mobile = document.getElementById('return-type-filter-input-mobile');
36+
37+
if (!regular || !mobile) return;
38+
39+
const regularInput = regular as HTMLInputElement;
40+
const mobileInput = mobile as HTMLInputElement;
41+
42+
syncReturnTypeInput(regularInput, mobileInput);
43+
44+
regularInput.addEventListener('input', () => {
45+
mobileInput.value = regularInput.value;
46+
handleReturnTypeFilterChange(regularInput);
47+
});
48+
49+
mobileInput.addEventListener('input', () => {
50+
regularInput.value = mobileInput.value;
51+
handleReturnTypeFilterChange(mobileInput);
52+
});
53+
}
54+
55+
function initReturnTypeFilter() {
56+
const syntaxDisplayManager = (window as any).__syntaxDisplayManager;
57+
if (!syntaxDisplayManager) {
58+
setTimeout(initReturnTypeFilter, 100);
59+
return;
60+
}
61+
62+
updateReturnTypeFilter = syntaxDisplayManager.updateReturnTypeFilter;
63+
64+
const typeParam = getParam('return_type');
65+
if (typeParam) {
66+
expectedType = typeParam;
67+
}
68+
69+
setupMirroredInputs();
70+
71+
if (typeParam && updateReturnTypeFilter) {
72+
updateReturnTypeFilter(expectedType);
73+
}
74+
}
75+
76+
initReturnTypeFilter();
77+
</script>
78+
79+
<fieldset id={`return-type-filter-fieldset${suffix}`} class="text-input">
80+
<legend>Return Type</legend>
81+
<label for={`return-type-filter-input${suffix}`}>
82+
<SearchableDropdown isMobile={isMobile} options={returnTypeOptions} />
83+
</label>
84+
</fieldset>
85+
86+
<style>
87+
fieldset {
88+
border-color: var(--sl-color-hairline);
89+
border-style: solid;
90+
padding: 1rem;
91+
}
92+
93+
fieldset > legend {
94+
font-size: 1.5rem;
95+
}
96+
97+
label {
98+
user-select: none;
99+
cursor: pointer;
100+
display: block;
101+
}
102+
103+
/* Override SearchableDropdown styles to match your theme */
104+
:global(.searchable-dropdown) {
105+
width: 100%;
106+
}
107+
108+
:global(.dropdown-input) {
109+
width: 100%;
110+
padding: 0.5rem;
111+
border: 1px solid var(--sl-color-gray-5);
112+
border-radius: 0.25rem;
113+
background-color: var(--sl-color-bg);
114+
color: var(--sl-color-white);
115+
font-size: 1rem;
116+
}
117+
118+
:global(.dropdown-input:focus) {
119+
outline: none;
120+
border-color: var(--sl-color-accent);
121+
}
122+
123+
:global(.dropdown-list) {
124+
background-color: var(--sl-color-bg);
125+
border-color: var(--sl-color-gray-5);
126+
color: var(--sl-color-white);
127+
}
128+
129+
:global(.dropdown-item:hover) {
130+
background-color: var(--sl-color-gray-6);
131+
}
132+
133+
:global(.no-results) {
134+
background-color: var(--sl-color-bg);
135+
border-color: var(--sl-color-gray-5);
136+
color: var(--sl-color-gray-3);
137+
}
138+
</style>

0 commit comments

Comments
 (0)