Skip to content

Commit ec896eb

Browse files
committed
feat: implement custom select component for enhanced dropdown functionality; replace standard selects in UI with custom implementation for improved styling and accessibility
1 parent 1dff547 commit ec896eb

5 files changed

Lines changed: 619 additions & 216 deletions

File tree

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
export interface CustomSelectOption {
2+
value: string;
3+
label: string;
4+
}
5+
6+
export interface CustomSelectHandle {
7+
getValue: () => string;
8+
setValue: (value: string) => void;
9+
setOptions: (options: CustomSelectOption[]) => void;
10+
setHidden: (hidden: boolean) => void;
11+
close: () => void;
12+
destroy: () => void;
13+
}
14+
15+
interface MountCustomSelectOptions {
16+
options: CustomSelectOption[];
17+
value?: string;
18+
compact?: boolean;
19+
block?: boolean;
20+
ariaLabel?: string;
21+
onChange?: (value: string) => void;
22+
}
23+
24+
const CHECK_ICON =
25+
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>';
26+
27+
const CHEVRON_ICON =
28+
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="custom-select-chevron"><path d="m6 9 6 6 6-6"/></svg>';
29+
30+
let openSelect: CustomSelectHandle | null = null;
31+
32+
export function closeAllCustomSelects(): void {
33+
openSelect?.close();
34+
}
35+
36+
export function mountCustomSelect(
37+
container: HTMLElement,
38+
config: MountCustomSelectOptions
39+
): CustomSelectHandle {
40+
container.innerHTML = "";
41+
container.classList.add("custom-select");
42+
if (config.compact) container.classList.add("custom-select--compact");
43+
if (config.block) container.classList.add("custom-select--block");
44+
45+
let options = [...config.options];
46+
let value = config.value ?? options[0]?.value ?? "";
47+
48+
const trigger = document.createElement("button");
49+
trigger.type = "button";
50+
trigger.className = "custom-select-trigger";
51+
trigger.setAttribute("aria-haspopup", "listbox");
52+
trigger.setAttribute("aria-expanded", "false");
53+
if (config.ariaLabel) trigger.setAttribute("aria-label", config.ariaLabel);
54+
55+
const labelEl = document.createElement("span");
56+
labelEl.className = "custom-select-label truncate";
57+
58+
trigger.append(labelEl, createChevron());
59+
60+
const menu = document.createElement("div");
61+
menu.className =
62+
"menu-panel menu-panel-animate custom-select-menu custom-scrollbar hidden absolute z-[120] min-w-full overflow-y-auto";
63+
menu.setAttribute("role", "listbox");
64+
65+
container.append(trigger, menu);
66+
67+
function labelForValue(nextValue: string): string {
68+
return options.find((option) => option.value === nextValue)?.label ?? nextValue;
69+
}
70+
71+
function renderOptions(): void {
72+
menu.innerHTML = options
73+
.map((option) => {
74+
const selected = option.value === value;
75+
return `
76+
<button
77+
type="button"
78+
role="option"
79+
class="custom-select-option${selected ? " custom-select-option--selected" : ""}"
80+
data-value="${escapeAttr(option.value)}"
81+
aria-selected="${selected ? "true" : "false"}"
82+
>
83+
<span class="truncate">${escapeHtml(option.label)}</span>
84+
<span class="custom-select-check">${selected ? CHECK_ICON : ""}</span>
85+
</button>`;
86+
})
87+
.join("");
88+
}
89+
90+
function syncTrigger(): void {
91+
labelEl.textContent = labelForValue(value);
92+
trigger.setAttribute("aria-expanded", menu.classList.contains("hidden") ? "false" : "true");
93+
container.classList.toggle("custom-select--open", !menu.classList.contains("hidden"));
94+
}
95+
96+
function close(): void {
97+
if (menu.classList.contains("hidden")) return;
98+
menu.classList.add("hidden");
99+
syncTrigger();
100+
if (openSelect === handle) openSelect = null;
101+
}
102+
103+
function open(): void {
104+
closeAllCustomSelects();
105+
menu.classList.remove("hidden");
106+
syncTrigger();
107+
openSelect = handle;
108+
const selected = menu.querySelector(".custom-select-option--selected") as HTMLElement | null;
109+
selected?.focus({ preventScroll: true });
110+
}
111+
112+
function setValue(nextValue: string): void {
113+
if (!options.some((option) => option.value === nextValue)) return;
114+
value = nextValue;
115+
renderOptions();
116+
syncTrigger();
117+
}
118+
119+
const handle: CustomSelectHandle = {
120+
getValue: () => value,
121+
setValue,
122+
setOptions(nextOptions: CustomSelectOption[]) {
123+
options = [...nextOptions];
124+
if (!options.some((option) => option.value === value)) {
125+
value = options[0]?.value ?? "";
126+
}
127+
renderOptions();
128+
syncTrigger();
129+
},
130+
setHidden(hidden: boolean) {
131+
container.classList.toggle("hidden", hidden);
132+
if (hidden) close();
133+
},
134+
close,
135+
destroy() {
136+
close();
137+
container.replaceChildren();
138+
container.classList.remove("custom-select", "custom-select--compact", "custom-select--block", "custom-select--open");
139+
trigger.removeEventListener("click", onTriggerClick);
140+
menu.removeEventListener("click", onMenuClick);
141+
container.removeEventListener("click", stopPropagation);
142+
},
143+
};
144+
145+
function onTriggerClick(event: MouseEvent): void {
146+
event.stopPropagation();
147+
if (menu.classList.contains("hidden")) open();
148+
else close();
149+
}
150+
151+
function onMenuClick(event: MouseEvent): void {
152+
event.stopPropagation();
153+
const option = (event.target as HTMLElement).closest(".custom-select-option") as HTMLElement | null;
154+
if (!option) return;
155+
const nextValue = option.getAttribute("data-value");
156+
if (!nextValue || nextValue === value) {
157+
close();
158+
return;
159+
}
160+
value = nextValue;
161+
renderOptions();
162+
syncTrigger();
163+
close();
164+
config.onChange?.(value);
165+
}
166+
167+
function stopPropagation(event: MouseEvent): void {
168+
event.stopPropagation();
169+
}
170+
171+
trigger.addEventListener("click", onTriggerClick);
172+
menu.addEventListener("click", onMenuClick);
173+
container.addEventListener("click", stopPropagation);
174+
175+
menu.addEventListener("keydown", (event) => {
176+
const items = [...menu.querySelectorAll<HTMLButtonElement>(".custom-select-option")];
177+
const currentIndex = items.findIndex((item) => item === document.activeElement);
178+
if (event.key === "Escape") {
179+
event.preventDefault();
180+
close();
181+
trigger.focus();
182+
return;
183+
}
184+
if (event.key === "ArrowDown") {
185+
event.preventDefault();
186+
const next = items[Math.min(currentIndex + 1, items.length - 1)] ?? items[0];
187+
next?.focus();
188+
return;
189+
}
190+
if (event.key === "ArrowUp") {
191+
event.preventDefault();
192+
const next = items[Math.max(currentIndex - 1, 0)] ?? items[items.length - 1];
193+
next?.focus();
194+
return;
195+
}
196+
if (event.key === "Enter" || event.key === " ") {
197+
event.preventDefault();
198+
(document.activeElement as HTMLElement | null)?.click();
199+
}
200+
});
201+
202+
if (config.value !== undefined) value = config.value;
203+
renderOptions();
204+
syncTrigger();
205+
206+
return handle;
207+
}
208+
209+
function createChevron(): HTMLElement {
210+
const wrap = document.createElement("span");
211+
wrap.innerHTML = CHEVRON_ICON;
212+
return wrap.firstElementChild as HTMLElement;
213+
}
214+
215+
function escapeHtml(text: string): string {
216+
return text
217+
.replace(/&/g, "&amp;")
218+
.replace(/</g, "&lt;")
219+
.replace(/>/g, "&gt;")
220+
.replace(/"/g, "&quot;");
221+
}
222+
223+
function escapeAttr(text: string): string {
224+
return escapeHtml(text).replace(/'/g, "&#39;");
225+
}

entrypoints/manager/index.html

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ <h2 id="current-view-title" class="text-xl font-bold truncate text-balance">All
115115
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M7 12h10"/><path d="M10 18h4"/></svg>
116116
Move to…
117117
</button>
118-
<div id="move-to-section-popover" role="menu" aria-label="Move to section" class="hidden absolute right-0 top-full mt-1.5 z-50 min-w-[220px] max-h-64 overflow-y-auto rounded-xl border border-border bg-surface-elevated/98 backdrop-blur-xl shadow-2xl py-1.5 custom-scrollbar ctx-menu ctx-menu--dropdown"></div>
118+
<div id="move-to-section-popover" role="menu" aria-label="Move to section" class="menu-panel menu-panel-animate menu-panel-animate--from-right custom-scrollbar hidden absolute right-0 top-full z-50 mt-1.5 max-h-64 min-w-[220px] overflow-y-auto"></div>
119119
</div>
120120
<button id="btn-close-selected" class="px-3 py-1.5 bg-surface-hover hover:bg-red-500/10 hover:text-red-500 text-text-secondary text-xs font-medium rounded-md transition-[background-color,color] border-0 cursor-pointer whitespace-nowrap active:scale-[0.96]">
121121
Close Selected
@@ -178,13 +178,7 @@ <h2 id="current-view-title" class="text-xl font-bold truncate text-balance">All
178178

179179
<div class="flex items-center gap-2">
180180
<span class="text-[10px] uppercase font-bold text-text-muted tracking-wider">Sort By</span>
181-
<select id="sort-select" class="manager-select manager-select--compact bg-surface-elevated border border-border text-text-secondary text-xs rounded-md px-2 py-1 outline-none cursor-pointer">
182-
<option value="index-asc">Original Order</option>
183-
<option value="duration-desc" selected>Duration (High to Low)</option>
184-
<option value="duration-asc">Duration (Low to High)</option>
185-
<option value="title-asc">Title (A-Z)</option>
186-
<option value="channel-asc">Channel (A-Z)</option>
187-
</select>
181+
<div id="sort-select" class="custom-select custom-select--compact"></div>
188182
</div>
189183

190184
<div class="h-4 w-px bg-border"></div>
@@ -285,7 +279,7 @@ <h3 id="save-session-title" class="text-lg font-bold text-text-primary mb-2">Sav
285279
<label class="flex items-center gap-2 text-sm text-text-secondary cursor-pointer"><input type="radio" name="save-session-mode" value="new" class="accent-accent" checked /> Save as new session</label>
286280
<label class="flex items-center gap-2 text-sm text-text-secondary cursor-pointer"><input type="radio" name="save-session-mode" value="replace" class="accent-accent" /> Replace existing session</label>
287281
<label class="flex items-center gap-2 text-sm text-text-secondary cursor-pointer"><input type="radio" name="save-session-mode" value="merge" class="accent-accent" /> Merge into existing session</label>
288-
<select id="save-session-target" class="manager-select hidden w-full mt-1 px-3 py-2 text-sm border border-border rounded-md bg-surface text-text-primary"></select>
282+
<div id="save-session-target" class="custom-select custom-select--block hidden mt-1"></div>
289283
<p id="save-session-duplicate-hint" class="hidden text-xs text-accent leading-relaxed"></p>
290284
</div>
291285
<div class="flex justify-end gap-2">
@@ -302,11 +296,8 @@ <h3 id="restore-session-title" class="text-lg font-bold text-text-primary mb-2">
302296
<p id="restore-session-summary" class="text-xs text-text-muted mb-4"></p>
303297
<div class="space-y-3 mb-5">
304298
<div>
305-
<label for="restore-target-window" class="text-[10px] uppercase font-bold text-text-muted tracking-wider">Open in</label>
306-
<select id="restore-target-window" class="manager-select w-full mt-1 px-3 py-2 text-sm border border-border rounded-md bg-surface text-text-primary">
307-
<option value="new">New window</option>
308-
<option value="current">Current window</option>
309-
</select>
299+
<label class="text-[10px] uppercase font-bold text-text-muted tracking-wider">Open in</label>
300+
<div id="restore-target-window" class="custom-select custom-select--block mt-1"></div>
310301
</div>
311302
<label class="flex items-center gap-2 text-sm text-text-secondary cursor-pointer"><input type="checkbox" id="restore-missing-only" class="accent-accent rounded" /> Open only tabs not already open</label>
312303
<label class="flex items-center gap-2 text-sm text-text-secondary cursor-pointer"><input type="checkbox" id="restore-by-sections" class="accent-accent rounded" /> One window per section (when sections exist)</label>
@@ -371,13 +362,13 @@ <h3 id="add-section-title" class="font-['Syne',sans-serif] text-xl font-semibold
371362
<div id="toast" class="hidden fixed bottom-6 left-1/2 -translate-x-1/2 z-60 px-4 py-2 rounded-lg bg-surface-elevated border border-border shadow-lg text-sm text-text-primary transition-opacity duration-300" role="status" aria-live="polite"></div>
372363

373364
<!-- Video / tab context: move to section -->
374-
<div id="tab-section-context-menu" role="menu" aria-label="Move to section" class="hidden fixed z-[90] min-w-[220px] max-h-72 overflow-y-auto py-1.5 rounded-xl border border-border bg-surface-elevated/98 backdrop-blur-xl shadow-2xl text-sm custom-scrollbar ctx-menu">
375-
<div class="ctx-menu-header">Move to section</div>
365+
<div id="tab-section-context-menu" role="menu" aria-label="Move to section" class="menu-panel menu-panel-animate custom-scrollbar hidden fixed z-[90] max-h-72 min-w-[220px] overflow-y-auto">
366+
<div class="menu-panel-header">Move to section</div>
376367
<div id="tab-section-context-items" class="px-1 pb-1"></div>
377368
</div>
378369

379370
<!-- Section header context -->
380-
<div id="section-header-context-menu" role="menu" aria-label="Section actions" class="hidden fixed z-[90] min-w-[200px] py-1.5 px-1 rounded-xl border border-border bg-surface-elevated/98 backdrop-blur-xl shadow-2xl text-sm ctx-menu">
371+
<div id="section-header-context-menu" role="menu" aria-label="Section actions" class="menu-panel menu-panel-animate hidden fixed z-[90] min-w-[200px]">
381372
<button type="button" id="section-ctx-rename" role="menuitem" class="ctx-menu-item">
382373
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
383374
Rename…
@@ -390,7 +381,7 @@ <h3 id="add-section-title" class="font-['Syne',sans-serif] text-xl font-semibold
390381
</div>
391382

392383
<!-- Right-click context menu for sidebar items -->
393-
<div id="sidebar-context-menu" role="menu" aria-label="Sidebar actions" class="hidden fixed z-[100] min-w-[200px] py-1.5 px-1 rounded-xl border border-border bg-surface-elevated/98 backdrop-blur-xl shadow-2xl text-sm ctx-menu">
384+
<div id="sidebar-context-menu" role="menu" aria-label="Sidebar actions" class="menu-panel menu-panel-animate hidden fixed z-[100] min-w-[200px]">
394385
<div id="ctx-group-save" class="hidden">
395386
<button type="button" id="ctx-save" role="menuitem" class="ctx-menu-item">
396387
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>

0 commit comments

Comments
 (0)