1
0

WIP: custom dropdown + One Dark Pro theme

This commit is contained in:
Maksim Totmin 2026-06-22 09:59:20 +07:00
parent e8b4eafc2e
commit 1d10191d90
3 changed files with 386 additions and 95 deletions

View File

@ -47,6 +47,40 @@ document.addEventListener('alpine:init', () => {
keyboard: 'Keyboard shortcut',
},
// ── Dropdown option sets (declarative — add a new dropdown = add a list) ──
actionTypeOptions: [
{ value: 'none', label: 'None' },
{ value: 'command', label: 'Command' },
{ value: 'builtin', label: 'Builtin' },
{ value: 'script', label: 'Script' },
{ value: 'page', label: 'Switch page' },
{ value: 'keyboard', label: 'Keyboard shortcut' },
],
builtinOptions: [
{ value: '', label: '-- select --' },
{ value: 'volume_up', label: 'Volume Up' },
{ value: 'volume_down', label: 'Volume Down' },
{ value: 'volume_mute', label: 'Volume Mute' },
{ value: 'brightness_up', label: 'Brightness Up' },
{ value: 'brightness_down', label: 'Brightness Down' },
{ value: 'media_play_pause', label: 'Play/Pause' },
{ value: 'media_next', label: 'Next Track' },
{ value: 'media_prev', label: 'Previous Track' },
{ value: 'media_stop', label: 'Stop' },
],
fontOptions: [
{ value: 'medium', label: 'Medium' },
{ value: 'regular', label: 'Regular' },
],
// Pages are dynamic — exposed as a getter so dropdowns stay reactive.
get pageOptions() {
return [
{ value: '', label: '-- select --' },
...this.pages.map(p => ({ value: p.name, label: p.name })),
]
},
// ── Init ──
async init() {
await this.loadConfig()
@ -720,4 +754,74 @@ document.addEventListener('alpine:init', () => {
return m[1].replace('T', ' ')
},
}))
// ── Reusable dropdown component ──
// Usage:
// x-data="dropdown({ get: () => model, set: v => model = v, options: [...] })"
// `options` may be an array or a function returning one (for reactive lists).
Alpine.data('dropdown', (config = {}) => ({
open: false,
activeIndex: -1,
_get: config.get || (() => ''),
_set: config.set || (() => {}),
_options: config.options || [],
placeholder: config.placeholder || '-- select --',
get optionList() {
return typeof this._options === 'function' ? this._options() : this._options
},
get selectedValue() {
return this._get()
},
get selectedLabel() {
const opt = this.optionList.find(o => o.value === this.selectedValue)
return opt ? opt.label : this.placeholder
},
get isPlaceholder() {
const v = this.selectedValue
const opt = this.optionList.find(o => o.value === v)
return v === '' || v == null || !opt
},
isSelected(value) {
return value === this.selectedValue
},
toggle() {
this.open = !this.open
if (this.open) {
this.activeIndex = this.optionList.findIndex(o => o.value === this.selectedValue)
}
},
close() {
this.open = false
this.activeIndex = -1
},
select(value) {
this._set(value)
this.close()
},
onTriggerKey(e) {
if (!this.open) {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
e.preventDefault()
this.toggle()
}
return
}
const list = this.optionList
if (e.key === 'ArrowDown') {
e.preventDefault()
this.activeIndex = (this.activeIndex + 1) % list.length
} else if (e.key === 'ArrowUp') {
e.preventDefault()
this.activeIndex = (this.activeIndex - 1 + list.length) % list.length
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
if (this.activeIndex >= 0) this.select(list[this.activeIndex].value)
} else if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation() // keep Escape from also closing the parent modal
this.close()
}
},
}))
})

View File

@ -117,10 +117,20 @@
</div>
<div class="form-group">
<label>Default Font</label>
<select x-model="settingsForm.font">
<option value="medium">Medium</option>
<option value="regular">Regular</option>
</select>
<div class="dropdown" x-data="dropdown({ get: () => settingsForm.font, set: v => settingsForm.font = v, options: fontOptions })" @click.outside="close()">
<button type="button" class="dropdown-trigger" :class="{ 'is-placeholder': isPlaceholder }" @click="toggle()" @keydown="onTriggerKey($event)">
<span class="dropdown-text" x-text="selectedLabel"></span>
<i class="fas fa-chevron-down dropdown-arrow"></i>
</button>
<div class="dropdown-menu" x-show="open" x-transition x-cloak>
<template x-for="(opt, oi) in optionList" :key="opt.value">
<button type="button" class="dropdown-option" :class="{ selected: isSelected(opt.value), active: activeIndex === oi }" @click="select(opt.value)" @mouseenter="activeIndex = oi">
<span x-text="opt.label"></span>
<i class="fas fa-check dropdown-check"></i>
</button>
</template>
</div>
</div>
</div>
<div class="checkbox-row">
<input type="checkbox" id="show-label-bg" x-model="settingsForm.show_label_background">
@ -185,12 +195,20 @@
<div class="form-row">
<div class="form-group" style="margin-bottom:0;">
<label>Page</label>
<select x-model="rule.page">
<option value="">--</option>
<template x-for="p in pages" :key="p.name">
<option :value="p.name" x-text="p.name"></option>
<div class="dropdown" x-data="dropdown({ get: () => rule.page, set: v => rule.page = v, options: () => pageOptions })" @click.outside="close()">
<button type="button" class="dropdown-trigger" :class="{ 'is-placeholder': isPlaceholder }" @click="toggle()" @keydown="onTriggerKey($event)">
<span class="dropdown-text" x-text="selectedLabel"></span>
<i class="fas fa-chevron-down dropdown-arrow"></i>
</button>
<div class="dropdown-menu" x-show="open" x-transition x-cloak>
<template x-for="(opt, oi) in optionList" :key="opt.value">
<button type="button" class="dropdown-option" :class="{ selected: isSelected(opt.value), active: activeIndex === oi }" @click="select(opt.value)" @mouseenter="activeIndex = oi">
<span x-text="opt.label"></span>
<i class="fas fa-check dropdown-check"></i>
</button>
</template>
</select>
</div>
</div>
</div>
<div class="checkbox-row" style="margin-bottom:0;padding-top:16px;">
<input type="checkbox" x-model="rule.stay">
@ -301,14 +319,20 @@
<template x-for="t in ['tap','long_press','double_tap']" :key="t">
<div x-show="editForm.activeTrigger === t">
<div class="form-group">
<select x-model="editForm.actions[t].type">
<option value="none">None</option>
<option value="command">Command</option>
<option value="builtin">Builtin</option>
<option value="script">Script</option>
<option value="page">Switch page</option>
<option value="keyboard">Keyboard shortcut</option>
</select>
<div class="dropdown" x-data="dropdown({ get: () => editForm.actions[t].type, set: v => editForm.actions[t].type = v, options: actionTypeOptions })" @click.outside="close()">
<button type="button" class="dropdown-trigger" :class="{ 'is-placeholder': isPlaceholder }" @click="toggle()" @keydown="onTriggerKey($event)">
<span class="dropdown-text" x-text="selectedLabel"></span>
<i class="fas fa-chevron-down dropdown-arrow"></i>
</button>
<div class="dropdown-menu" x-show="open" x-transition x-cloak>
<template x-for="(opt, oi) in optionList" :key="opt.value">
<button type="button" class="dropdown-option" :class="{ selected: isSelected(opt.value), active: activeIndex === oi }" @click="select(opt.value)" @mouseenter="activeIndex = oi">
<span x-text="opt.label"></span>
<i class="fas fa-check dropdown-check"></i>
</button>
</template>
</div>
</div>
</div>
<template x-if="editForm.actions[t].type === 'command'">
@ -325,18 +349,20 @@
<template x-if="editForm.actions[t].type === 'builtin'">
<div class="form-group">
<select x-model="editForm.actions[t].builtin">
<option value="">-- select --</option>
<option value="volume_up">Volume Up</option>
<option value="volume_down">Volume Down</option>
<option value="volume_mute">Volume Mute</option>
<option value="brightness_up">Brightness Up</option>
<option value="brightness_down">Brightness Down</option>
<option value="media_play_pause">Play/Pause</option>
<option value="media_next">Next Track</option>
<option value="media_prev">Previous Track</option>
<option value="media_stop">Stop</option>
</select>
<div class="dropdown" x-data="dropdown({ get: () => editForm.actions[t].builtin, set: v => editForm.actions[t].builtin = v, options: builtinOptions })" @click.outside="close()">
<button type="button" class="dropdown-trigger" :class="{ 'is-placeholder': isPlaceholder }" @click="toggle()" @keydown="onTriggerKey($event)">
<span class="dropdown-text" x-text="selectedLabel"></span>
<i class="fas fa-chevron-down dropdown-arrow"></i>
</button>
<div class="dropdown-menu" x-show="open" x-transition x-cloak>
<template x-for="(opt, oi) in optionList" :key="opt.value">
<button type="button" class="dropdown-option" :class="{ selected: isSelected(opt.value), active: activeIndex === oi }" @click="select(opt.value)" @mouseenter="activeIndex = oi">
<span x-text="opt.label"></span>
<i class="fas fa-check dropdown-check"></i>
</button>
</template>
</div>
</div>
</div>
</template>
@ -347,12 +373,20 @@
</template>
<div class="form-group" x-show="editForm.actions[t].type === 'page'">
<select x-model="editForm.actions[t].page">
<option value="">-- select --</option>
<template x-for="p in pages" :key="p.name">
<option :value="p.name" x-text="p.name"></option>
<div class="dropdown" x-data="dropdown({ get: () => editForm.actions[t].page, set: v => editForm.actions[t].page = v, options: () => pageOptions })" @click.outside="close()">
<button type="button" class="dropdown-trigger" :class="{ 'is-placeholder': isPlaceholder }" @click="toggle()" @keydown="onTriggerKey($event)">
<span class="dropdown-text" x-text="selectedLabel"></span>
<i class="fas fa-chevron-down dropdown-arrow"></i>
</button>
<div class="dropdown-menu" x-show="open" x-transition x-cloak>
<template x-for="(opt, oi) in optionList" :key="opt.value">
<button type="button" class="dropdown-option" :class="{ selected: isSelected(opt.value), active: activeIndex === oi }" @click="select(opt.value)" @mouseenter="activeIndex = oi">
<span x-text="opt.label"></span>
<i class="fas fa-check dropdown-check"></i>
</button>
</template>
</select>
</div>
</div>
</div>
<template x-if="editForm.actions[t].type === 'keyboard'">
@ -398,14 +432,20 @@
<template x-for="phase in ['start','end']" :key="phase">
<div x-show="editForm.holdPhase === phase">
<div class="form-group">
<select x-model="editForm.actions.hold[phase].type">
<option value="none">None</option>
<option value="command">Command</option>
<option value="builtin">Builtin</option>
<option value="script">Script</option>
<option value="page">Switch page</option>
<option value="keyboard">Keyboard shortcut</option>
</select>
<div class="dropdown" x-data="dropdown({ get: () => editForm.actions.hold[phase].type, set: v => editForm.actions.hold[phase].type = v, options: actionTypeOptions })" @click.outside="close()">
<button type="button" class="dropdown-trigger" :class="{ 'is-placeholder': isPlaceholder }" @click="toggle()" @keydown="onTriggerKey($event)">
<span class="dropdown-text" x-text="selectedLabel"></span>
<i class="fas fa-chevron-down dropdown-arrow"></i>
</button>
<div class="dropdown-menu" x-show="open" x-transition x-cloak>
<template x-for="(opt, oi) in optionList" :key="opt.value">
<button type="button" class="dropdown-option" :class="{ selected: isSelected(opt.value), active: activeIndex === oi }" @click="select(opt.value)" @mouseenter="activeIndex = oi">
<span x-text="opt.label"></span>
<i class="fas fa-check dropdown-check"></i>
</button>
</template>
</div>
</div>
</div>
<template x-if="editForm.actions.hold[phase].type === 'command'">
@ -422,18 +462,20 @@
<template x-if="editForm.actions.hold[phase].type === 'builtin'">
<div class="form-group">
<select x-model="editForm.actions.hold[phase].builtin">
<option value="">-- select --</option>
<option value="volume_up">Volume Up</option>
<option value="volume_down">Volume Down</option>
<option value="volume_mute">Volume Mute</option>
<option value="brightness_up">Brightness Up</option>
<option value="brightness_down">Brightness Down</option>
<option value="media_play_pause">Play/Pause</option>
<option value="media_next">Next Track</option>
<option value="media_prev">Previous Track</option>
<option value="media_stop">Stop</option>
</select>
<div class="dropdown" x-data="dropdown({ get: () => editForm.actions.hold[phase].builtin, set: v => editForm.actions.hold[phase].builtin = v, options: builtinOptions })" @click.outside="close()">
<button type="button" class="dropdown-trigger" :class="{ 'is-placeholder': isPlaceholder }" @click="toggle()" @keydown="onTriggerKey($event)">
<span class="dropdown-text" x-text="selectedLabel"></span>
<i class="fas fa-chevron-down dropdown-arrow"></i>
</button>
<div class="dropdown-menu" x-show="open" x-transition x-cloak>
<template x-for="(opt, oi) in optionList" :key="opt.value">
<button type="button" class="dropdown-option" :class="{ selected: isSelected(opt.value), active: activeIndex === oi }" @click="select(opt.value)" @mouseenter="activeIndex = oi">
<span x-text="opt.label"></span>
<i class="fas fa-check dropdown-check"></i>
</button>
</template>
</div>
</div>
</div>
</template>
@ -444,12 +486,20 @@
</template>
<div class="form-group" x-show="editForm.actions.hold[phase].type === 'page'">
<select x-model="editForm.actions.hold[phase].page">
<option value="">-- select --</option>
<template x-for="p in pages" :key="p.name">
<option :value="p.name" x-text="p.name"></option>
<div class="dropdown" x-data="dropdown({ get: () => editForm.actions.hold[phase].page, set: v => editForm.actions.hold[phase].page = v, options: () => pageOptions })" @click.outside="close()">
<button type="button" class="dropdown-trigger" :class="{ 'is-placeholder': isPlaceholder }" @click="toggle()" @keydown="onTriggerKey($event)">
<span class="dropdown-text" x-text="selectedLabel"></span>
<i class="fas fa-chevron-down dropdown-arrow"></i>
</button>
<div class="dropdown-menu" x-show="open" x-transition x-cloak>
<template x-for="(opt, oi) in optionList" :key="opt.value">
<button type="button" class="dropdown-option" :class="{ selected: isSelected(opt.value), active: activeIndex === oi }" @click="select(opt.value)" @mouseenter="activeIndex = oi">
<span x-text="opt.label"></span>
<i class="fas fa-check dropdown-check"></i>
</button>
</template>
</select>
</div>
</div>
</div>
<template x-if="editForm.actions.hold[phase].type === 'keyboard'">

View File

@ -1,24 +1,52 @@
:root {
--bg: #0a0a10;
--surface: #141420;
--surface-hover: #1a1a2a;
--border: #252540;
--border-light: #333358;
--text: #e4e4f0;
--text-muted: #7a7a90;
--accent: #6366f1;
--accent-glow: rgba(99, 102, 241, 0.3);
--danger: #ef4444;
--success: #22c55e;
/* ── One Dark Pro palette ── */
/* Surfaces: layered dark greys built around One Dark's #282c34 base */
--bg: #282c34;
--surface: #2f343f;
--surface-hover: #3a3f4b;
--surface-sunken: #21252b;
--border: #3e4451;
--border-light: #4b5263;
/* Text */
--text: #abb2bf;
--text-muted: #5c6370;
/* Accent — purple (One Dark Pro signature) */
--accent: #c678dd;
--accent-hover: #d292e8;
--accent-active: #b561d0;
--accent-glow: rgba(198, 120, 221, 0.28);
--accent-soft: rgba(198, 120, 221, 0.12);
--on-accent: #21252b; /* dark text on the bright accent — WCAG AA (5.3:1) */
/* Semantic */
--danger: #e06c75;
--danger-soft: rgba(224, 108, 117, 0.12);
--success: #98c379;
--warning: #e5c07b;
/* Radii */
--radius: 12px;
--radius-sm: 8px;
--radius-xs: 6px;
--shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
/* Elevation */
--shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
--shadow-sm: 0 2px 10px rgba(0, 0, 0, 0.35);
/* Motion */
--transition: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-spring: 220ms cubic-bezier(0.16, 1, 0.3, 1);
/* Typography */
--font-mono: 'SF Mono', 'Fira Code', 'JetBrains Mono', ui-monospace, monospace;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
[x-cloak] { display: none !important; }
html, body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', sans-serif;
@ -112,6 +140,7 @@ main {
background: var(--surface);
border-radius: var(--radius);
border: 1px solid var(--border);
box-shadow: var(--shadow-sm);
width: 100%;
max-width: 525px;
contain: layout style;
@ -140,7 +169,7 @@ main {
.deck-btn:hover { color: var(--text); border-color: var(--border-light); }
.deck-btn.active {
background: var(--accent);
color: #fff;
color: var(--on-accent);
border-color: var(--accent);
}
@ -148,7 +177,7 @@ main {
aspect-ratio: 1;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: #0d0d18;
background: var(--surface-sunken);
cursor: pointer;
overflow: hidden;
position: relative;
@ -164,8 +193,9 @@ main {
.key-btn:hover {
border-color: var(--accent);
box-shadow: 0 0 12px var(--accent-glow);
transform: scale(1.03);
box-shadow: 0 0 0 1px var(--accent), 0 4px 16px var(--accent-glow);
transform: scale(1.04);
z-index: 1;
}
.key-btn:active { transform: scale(0.97); }
@ -210,8 +240,9 @@ main {
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
z-index: 100;
display: flex;
align-items: center;
@ -257,7 +288,7 @@ main {
overflow: hidden;
border: 1px solid var(--border);
position: relative;
background: #0d0d18;
background: var(--surface-sunken);
cursor: pointer;
}
@ -303,38 +334,139 @@ main {
.form-group input,
.form-group select {
width: 100%;
padding: 8px 10px;
background: var(--bg);
padding: 9px 11px;
background: var(--surface-sunken);
border: 1px solid var(--border);
border-radius: var(--radius-xs);
color: var(--text);
font-size: 13px;
font-family: inherit;
outline: none;
transition: border-color var(--transition);
transition: border-color var(--transition), box-shadow var(--transition), background var(--transition);
}
.form-group input:hover,
.form-group select:hover {
border-color: var(--border-light);
}
.form-group input:focus,
.form-group select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-glow);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.form-group input::placeholder {
color: var(--text-muted);
opacity: 0.5;
opacity: 0.7;
}
.form-group select {
cursor: pointer;
-webkit-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M3 5l3 3 3-3' fill='none' stroke='%237a7a90' stroke-width='1.5'/%3E%3C/svg%3E");
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M3 5l3 3 3-3' fill='none' stroke='%235c6370' stroke-width='1.5'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
padding-right: 28px;
}
/* Custom dropdown
Inline-expand pattern: the menu lives in normal document flow, so it
can never be clipped by an ancestor with overflow:auto (e.g. the modal).
Reusable across every <select> replacement. */
.dropdown {
position: relative;
width: 100%;
}
.dropdown-trigger {
width: 100%;
padding: 9px 11px;
background: var(--surface-sunken);
border: 1px solid var(--border);
border-radius: var(--radius-xs);
color: var(--text);
font-size: 13px;
font-family: inherit;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
text-align: left;
transition: border-color var(--transition), box-shadow var(--transition), background var(--transition);
}
.dropdown-trigger:hover { border-color: var(--border-light); }
.dropdown.open > .dropdown-trigger,
.dropdown-trigger:focus-visible {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.dropdown-trigger .dropdown-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dropdown-trigger.is-placeholder .dropdown-text { color: var(--text-muted); }
.dropdown-arrow {
flex-shrink: 0;
font-size: 10px;
color: var(--text-muted);
transition: transform var(--transition), color var(--transition);
}
.dropdown.open > .dropdown-trigger .dropdown-arrow {
transform: rotate(180deg);
color: var(--accent);
}
.dropdown-menu {
margin-top: 4px;
background: var(--surface);
border: 1px solid var(--border-light);
border-radius: var(--radius-xs);
box-shadow: var(--shadow-sm);
overflow-y: auto;
max-height: 260px;
padding: 4px;
}
.dropdown-option {
width: 100%;
padding: 8px 10px;
background: none;
border: none;
border-radius: var(--radius-xs);
color: var(--text-muted);
font-size: 13px;
font-family: inherit;
text-align: left;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
transition: background var(--transition), color var(--transition);
}
.dropdown-option:hover,
.dropdown-option.active {
background: var(--surface-hover);
color: var(--text);
}
.dropdown-option.selected {
color: var(--accent);
background: var(--accent-soft);
}
.dropdown-option .dropdown-check {
font-size: 10px;
opacity: 0;
flex-shrink: 0;
}
.dropdown-option.selected .dropdown-check { opacity: 1; }
.form-row {
display: flex;
gap: 8px;
@ -401,18 +533,23 @@ main {
transition: all var(--transition);
}
.btn:active { transform: translateY(1px); }
.btn-primary {
background: var(--accent);
color: #fff;
color: var(--on-accent);
border-color: var(--accent);
flex: 1;
}
.btn-primary:hover {
background: #4f46e5;
box-shadow: 0 0 16px var(--accent-glow);
background: var(--accent-hover);
border-color: var(--accent-hover);
box-shadow: 0 4px 16px var(--accent-glow);
}
.btn-primary:active { background: var(--accent-active); }
.btn-outline {
background: transparent;
border-color: var(--border);
@ -655,7 +792,7 @@ main {
.tab-btn:hover { color: var(--text); }
.tab-btn.active {
background: var(--accent);
color: #fff;
color: var(--on-accent);
}
/* ── Modifier buttons ── */
@ -681,7 +818,7 @@ main {
.mod-btn:hover { color: var(--text); border-color: var(--border-light); }
.mod-btn.active {
background: var(--accent);
color: #fff;
color: var(--on-accent);
border-color: var(--accent);
}
@ -771,7 +908,7 @@ main {
.action-tab:hover { color: var(--text); border-color: var(--border-light); }
.action-tab.active {
background: var(--accent);
color: #fff;
color: var(--on-accent);
border-color: var(--accent);
}
.action-tab.configured {
@ -780,7 +917,7 @@ main {
}
.action-tab.active.configured {
background: var(--accent);
color: #fff;
color: var(--on-accent);
border-color: var(--accent);
}
.tab-check { font-size: 8px; }
@ -808,6 +945,6 @@ main {
.hold-stab:hover { color: var(--text); border-color: var(--border-light); }
.hold-stab.active {
background: var(--accent);
color: #fff;
color: var(--on-accent);
border-color: var(--accent);
}