@bw-ui/command-palette
Version:
Beautiful, accessible command palette for the web. Zero dependencies.
17 lines (16 loc) • 17.8 kB
JavaScript
function v(s,e){if(!s)return{score:1,matches:[]};let t=s.toLowerCase(),r=e.toLowerCase(),i=[],n=0,a=0,c=-1,l=0;for(let o=0;o<e.length&&n<s.length;o++)if(r[o]===t[n]){i.push(o);let d=1;c===o-1?(l+=.5,d+=l):l=0,(o===0||/[\s\-_.]/.test(e[o-1]))&&(d+=2),e[o]===s[n]&&(d+=.2),a+=d,c=o,n++}return n!==s.length?null:(a=a/Math.sqrt(e.length),r===t&&(a+=10),r.startsWith(t)&&(a+=5),{score:a,matches:i})}function k(s,e,t=50){if(!s.trim())return e.filter(i=>!i.hidden).slice(0,t).map(i=>({command:i,score:0,matches:[]}));let r=[];for(let i of e){if(i.hidden)continue;let n=v(s,i.label),a="label";if(i.keywords)for(let c of i.keywords){let l=v(s,c);l&&(!n||l.score>n.score)&&(n=l,a="keyword")}if(i.description){let c=v(s,i.description);c&&(c.score*=.5,(!n||c.score>n.score)&&(n=c,a="description"))}n&&r.push({command:i,score:n.score,matches:a==="label"?n.matches:[]})}return r.sort((i,n)=>n.score-i.score),r.slice(0,t)}function x(s,e){if(!e.length)return g(s);let t="",r=0;for(let i of e)t+=g(s.slice(r,i)),t+=`<mark class="bw-cmd-highlight">${g(s[i])}</mark>`,r=i+1;return t+=g(s.slice(r)),t}function g(s){let e=document.createElement("div");return e.textContent=s,e.innerHTML}var m=typeof navigator<"u"&&/Mac|iPod|iPhone|iPad/.test(navigator.platform);function y(s){let e=s.toLowerCase().split("+"),t=e.pop()||"";return{key:R(t),ctrl:e.includes("ctrl"),meta:e.includes("meta")||e.includes("cmd"),alt:e.includes("alt")||e.includes("option"),shift:e.includes("shift"),mod:e.includes("mod")}}function R(s){return{esc:"escape",return:"enter",space:" ",up:"arrowup",down:"arrowdown",left:"arrowleft",right:"arrowright"}[s]||s}function C(s,e){if(s.key.toLowerCase()!==e.key)return!1;if(e.mod){if(!(m?s.metaKey:s.ctrlKey))return!1}else if(e.ctrl&&!s.ctrlKey||e.meta&&!s.metaKey)return!1;return!(e.alt&&!s.altKey||e.shift&&!s.shiftKey)}function E(s){let e=y(s),t=[];e.mod&&t.push(m?"\u2318":"Ctrl"),e.ctrl&&!e.mod&&t.push(m?"\u2303":"Ctrl"),e.alt&&t.push(m?"\u2325":"Alt"),e.shift&&t.push(m?"\u21E7":"Shift"),e.meta&&!e.mod&&t.push(m?"\u2318":"Win");let r=K(e.key);return t.push(r),t.join(m?"":"+")}function K(s){return{arrowup:"\u2191",arrowdown:"\u2193",arrowleft:"\u2190",arrowright:"\u2192",enter:"\u21B5",escape:"Esc",backspace:"\u232B",delete:"\u2326",tab:"\u21E5"," ":"Space"}[s]||s.toUpperCase()}function S(s){return{handleKeyDown:t=>{let{getItemCount:r,getActiveIndex:i,setActiveIndex:n,onSelect:a,onClose:c,onBack:l}=s,o=r(),d=i();switch(t.key){case"ArrowDown":t.preventDefault(),n(o>0?(d+1)%o:0);break;case"ArrowUp":t.preventDefault(),n(o>0?(d-1+o)%o:0);break;case"Enter":t.preventDefault(),o>0&&a();break;case"Escape":t.preventDefault(),c();break;case"Backspace":l&&t.target.value===""&&(t.preventDefault(),l());break;case"Tab":t.preventDefault();break}},destroy:()=>{}}}var B="bw-command-palette-recent";function A(s={}){let{maxRecent:e=5,storageKey:t=B,persist:r=!0}=s,i=[];if(r&&typeof localStorage<"u")try{let a=localStorage.getItem(t);a&&(i=JSON.parse(a))}catch{}function n(){if(r&&typeof localStorage<"u")try{localStorage.setItem(t,JSON.stringify(i))}catch{}}return{add(a){i=i.filter(c=>c!==a),i.unshift(a),i=i.slice(0,e),n()},get(){return[...i]},clear(){i=[],n()},getScore(a){let c=i.indexOf(a);return c===-1?0:(e-c)/e}}}function I(){let s=document.createElement("div");s.setAttribute("role","status"),s.setAttribute("aria-live","polite"),s.setAttribute("aria-atomic","true"),s.className="bw-cmd-sr-only",document.body.appendChild(s);let e=null;return{announce(t,r="polite"){s.setAttribute("aria-live",r),s.textContent="",e&&clearTimeout(e),e=setTimeout(()=>{s.textContent=t},50)},destroy(){e&&clearTimeout(e),s.remove()}}}function T(s){let e=null,t=!1,r=()=>{let n=["input:not([disabled])","button:not([disabled])",'[tabindex]:not([tabindex="-1"])',"a[href]","select:not([disabled])","textarea:not([disabled])"].join(",");return Array.from(s.querySelectorAll(n))},i=n=>{if(!t||n.key!=="Tab")return;let a=r();if(a.length===0)return;let c=a[0],l=a[a.length-1];n.shiftKey&&document.activeElement===c?(n.preventDefault(),l.focus()):!n.shiftKey&&document.activeElement===l&&(n.preventDefault(),c.focus())};return{activate(){if(t)return;t=!0,e=document.activeElement;let n=r();n.length>0&&n[0].focus(),document.addEventListener("keydown",i)},deactivate(){t&&(t=!1,document.removeEventListener("keydown",i),e&&e.focus&&e.focus())},destroy(){this.deactivate()}}}var $=0;function L(s="bw-cmd"){return`${s}-${++$}`}function M(s,e){return e?s===0?`No commands found for "${e}"`:`${s} result${s!==1?"s":""} for "${e}"`:`${s} command${s!==1?"s":""} available`}function H(s){let e=L("bw-cmd-listbox"),t=L("bw-cmd-input"),r=document.createElement("div");r.className="bw-cmd-root",r.setAttribute("data-theme",s.theme||"system"),s.zIndex&&(r.style.zIndex=String(s.zIndex));let i=document.createElement("div");i.className="bw-cmd-backdrop";let n=document.createElement("div");n.className="bw-cmd-dialog",n.setAttribute("role","dialog"),n.setAttribute("aria-modal","true"),n.setAttribute("aria-label","Command palette"),s.width&&n.style.setProperty("--bw-cmd-width",s.width),s.maxHeight&&n.style.setProperty("--bw-cmd-max-height",s.maxHeight);let a=document.createElement("div");a.className="bw-cmd-header";let c=document.createElement("div");c.className="bw-cmd-search-icon",c.innerHTML='<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>';let l=document.createElement("div");l.className="bw-cmd-breadcrumb",l.setAttribute("aria-hidden","true");let o=document.createElement("input");o.type="text",o.id=t,o.className="bw-cmd-input",o.placeholder=s.placeholder||"Search commands...",o.setAttribute("role","combobox"),o.setAttribute("aria-expanded","true"),o.setAttribute("aria-controls",e),o.setAttribute("aria-autocomplete","list"),o.setAttribute("aria-activedescendant",""),o.setAttribute("autocomplete","off"),o.setAttribute("autocorrect","off"),o.setAttribute("autocapitalize","off"),o.setAttribute("spellcheck","false");let d=document.createElement("button");d.type="button",d.className="bw-cmd-clear",d.setAttribute("aria-label","Clear search"),d.innerHTML='<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>',a.appendChild(c),a.appendChild(l),a.appendChild(o),a.appendChild(d);let h=document.createElement("div");h.className="bw-cmd-content";let u=document.createElement("div");u.id=e,u.className="bw-cmd-list",u.setAttribute("role","listbox"),u.setAttribute("aria-label","Commands");let p=document.createElement("div");p.className="bw-cmd-empty",p.innerHTML=`
<div class="bw-cmd-empty-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/><path d="M8 8h6"/></svg>
</div>
<div class="bw-cmd-empty-text">${s.emptyMessage||"No commands found"}</div>
<div class="bw-cmd-empty-hint">Try a different search term</div>
`;let f=document.createElement("div");f.className="bw-cmd-loading",f.innerHTML=`
<div class="bw-cmd-spinner"></div>
<div class="bw-cmd-loading-text">${s.loadingMessage||"Searching..."}</div>
`,h.appendChild(u),h.appendChild(p),h.appendChild(f);let b=document.createElement("div");return b.className="bw-cmd-footer",b.innerHTML=`
<div class="bw-cmd-hint">
<kbd>\u2191\u2193</kbd> navigate
<kbd>\u21B5</kbd> select
<kbd>esc</kbd> close
</div>
`,n.appendChild(a),n.appendChild(h),n.appendChild(b),r.appendChild(i),r.appendChild(n),{root:r,backdrop:i,dialog:n,header:a,input:o,breadcrumb:l,content:h,list:u,empty:p,loading:f,footer:b,listboxId:e}}function N(s,e,t,r){let i=P(e);s.innerHTML="";let n=0;for(let a of i){if(a.name){let c=document.createElement("div");c.className="bw-cmd-group-header",c.textContent=a.name,c.setAttribute("role","presentation"),s.appendChild(c)}for(let c of a.items){let l=z(c,n,t===n),o=n;l.addEventListener("click",d=>{d.preventDefault(),r.onSelect(c.command,o)}),l.addEventListener("mouseenter",()=>{r.onHover(o)}),s.appendChild(l),n++}}}function P(s){let e=new Map;for(let r of s){let i=r.command.group||null;e.has(i)||e.set(i,[]),e.get(i).push(r)}let t=[];e.has(null)&&(t.push({name:null,items:e.get(null)}),e.delete(null));for(let[r,i]of e)t.push({name:r,items:i});return t}function z(s,e,t){let{command:r,matches:i}=s,n=document.createElement("div");if(n.className=`bw-cmd-item${t?" bw-cmd-item--active":""}${r.disabled?" bw-cmd-item--disabled":""}`,n.id=`bw-cmd-item-${e}`,n.setAttribute("role","option"),n.setAttribute("aria-selected",String(t)),n.setAttribute("aria-disabled",String(!!r.disabled)),n.setAttribute("data-index",String(e)),r.icon){let o=document.createElement("div");o.className="bw-cmd-item-icon",typeof r.icon=="string"?o.innerHTML=r.icon:o.appendChild(r.icon.cloneNode(!0)),n.appendChild(o)}let a=document.createElement("div");a.className="bw-cmd-item-content";let c=document.createElement("div");if(c.className="bw-cmd-item-label",c.innerHTML=x(r.label,i),a.appendChild(c),r.description){let o=document.createElement("div");o.className="bw-cmd-item-description",o.textContent=r.description,a.appendChild(o)}n.appendChild(a);let l=document.createElement("div");if(l.className="bw-cmd-item-right",r.children&&r.children.length>0)l.innerHTML='<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>',l.className+=" bw-cmd-item-arrow";else if(r.shortcut){let o=document.createElement("kbd");o.className="bw-cmd-kbd",o.textContent=E(r.shortcut),l.appendChild(o)}return n.appendChild(l),n}function w(s,e,t){if(s.innerHTML="",e.length===0){s.style.display="none";return}s.style.display="flex",e.forEach((r,i)=>{if(i>0){let a=document.createElement("span");a.className="bw-cmd-breadcrumb-sep",a.textContent="/",s.appendChild(a)}let n=document.createElement("button");n.type="button",n.className="bw-cmd-breadcrumb-item",n.textContent=r.label,n.addEventListener("click",()=>t(i)),s.appendChild(n)})}function D(s,e){let{list:t,empty:r,loading:i}=s;t.style.display=e.isLoading||e.isEmpty?"none":"block",r.style.display=e.isLoading?"none":e.isEmpty?"flex":"none",i.style.display=e.isLoading?"flex":"none",e.activeIndex>=0&&!e.isEmpty&&!e.isLoading?s.input.setAttribute("aria-activedescendant",`bw-cmd-item-${e.activeIndex}`):s.input.removeAttribute("aria-activedescendant")}function O(s,e){let t=s.querySelector(`[data-index="${e}"]`);t&&t.scrollIntoView({block:"nearest",behavior:"smooth"})}var F={trigger:"mod+k",placeholder:"Search commands...",emptyMessage:"No commands found",loadingMessage:"Searching...",closeOnSelect:!0,closeOnClickOutside:!0,closeOnEscape:!0,maxResults:50,debounceMs:150,rememberRecent:!0,maxRecent:5,theme:"system",animationDuration:150},_=class{constructor(e={}){this.options={...F,...e},this.elements=null,this.state={isOpen:!1,query:"",activeIndex:0,isLoading:!1,results:[],breadcrumb:[]},this.commands=[],this.commandProvider=null,e.commands&&(typeof e.commands=="function"?this.commandProvider=e.commands:this.commands=e.commands),this.mountTarget=null,this.history=A(),this.liveRegion=I(),this.focusTrap=null,this.navigationHandler=null,this.debounceTimer=null,this.eventListeners=new Map,this._boundHandleGlobalKeyDown=this._handleGlobalKeyDown.bind(this),this.options.trigger&&document.addEventListener("keydown",this._boundHandleGlobalKeyDown)}mount(e){return typeof e=="string"?this.mountTarget=document.querySelector(e):this.mountTarget=e,this.mountTarget?(this._createDOM(),this):(console.warn("[BWCommandPalette] Mount target not found"),this)}open(){return this.state.isOpen?this:(this.elements||this._createDOM(),this.state.isOpen=!0,this.state.query="",this.state.activeIndex=0,this.state.breadcrumb=[],this.elements&&!this.elements.root.parentElement&&document.body.appendChild(this.elements.root),requestAnimationFrame(()=>{this.elements&&(this.elements.root.classList.add("bw-cmd-root--open"),this.elements.input.value="",this.elements.input.focus(),this.focusTrap?.activate())}),this._performSearch(""),this._emit("open",void 0),this.options.onOpen?.(),this)}close(){return this.state.isOpen?(this.state.isOpen=!1,this.focusTrap?.deactivate(),this.elements&&(this.elements.root.classList.remove("bw-cmd-root--open"),setTimeout(()=>{this.elements?.root.parentElement&&!this.state.isOpen&&this.elements.root.remove()},this.options.animationDuration)),this._emit("close",void 0),this.options.onClose?.(),this):this}toggle(){return this.state.isOpen?this.close():this.open()}setCommands(e){return this.commands=e,this.commandProvider=null,this.state.isOpen&&this._performSearch(this.state.query),this}addCommand(e){return this.commands.push(e),this.state.isOpen&&this._performSearch(this.state.query),this}removeCommand(e){return this.commands=this.commands.filter(t=>t.id!==e),this.state.isOpen&&this._performSearch(this.state.query),this}on(e,t){return this.eventListeners.has(e)||this.eventListeners.set(e,new Set),this.eventListeners.get(e).add(t),this}off(e,t){return this.eventListeners.get(e)?.delete(t),this}destroy(){document.removeEventListener("keydown",this._boundHandleGlobalKeyDown),this.focusTrap?.destroy(),this.liveRegion.destroy(),this.elements?.root.remove(),this.elements=null,this.eventListeners.clear(),this.debounceTimer&&clearTimeout(this.debounceTimer)}_createDOM(){this.elements=H(this.options),this.focusTrap=T(this.elements.dialog),this.navigationHandler=S({getItemCount:()=>this.state.results.length,getActiveIndex:()=>this.state.activeIndex,setActiveIndex:t=>this._setActiveIndex(t),onSelect:()=>this._selectActive(),onClose:()=>this.close(),onBack:()=>this._navigateBack()}),this.elements.input.addEventListener("input",this._handleInput.bind(this)),this.elements.input.addEventListener("keydown",this._handleInputKeyDown.bind(this)),this.elements.header.querySelector(".bw-cmd-clear")?.addEventListener("click",()=>{this.elements&&(this.elements.input.value="",this.elements.input.focus(),this._handleInput())}),this.options.closeOnClickOutside&&this.elements.backdrop.addEventListener("click",()=>this.close())}_handleGlobalKeyDown(e){if(!this.options.trigger)return;let t=y(this.options.trigger);C(e,t)&&(e.preventDefault(),this.toggle())}_handleInputKeyDown(e){this.navigationHandler?.handleKeyDown(e)}_handleInput(){let e=this.elements?.input.value||"",t=this.elements?.header.querySelector(".bw-cmd-clear");t&&(t.style.opacity=e?"1":"0",t.style.pointerEvents=e?"auto":"none"),this.debounceTimer&&clearTimeout(this.debounceTimer),this.debounceTimer=setTimeout(()=>{this._performSearch(e)},this.options.debounceMs),this._emit("query",e),this.options.onQueryChange?.(e)}async _performSearch(e){this.state.query=e;let t;if(this.commandProvider&&typeof this.commandProvider=="function"){this.state.isLoading=!0,this._updateUI();try{t=await this.commandProvider(e)}catch(i){console.error("[BWCommandPalette] Command provider error:",i),t=[]}this.state.isLoading=!1}else this.state.breadcrumb.length>0?t=this.state.breadcrumb[this.state.breadcrumb.length-1].children||[]:t=this.commands;let r=k(e,t,this.options.maxResults);!e&&this.options.rememberRecent&&(r=this._boostRecent(r)),this.state.results=r,this.state.activeIndex=0,this._updateUI(),this.liveRegion.announce(M(r.length,e))}_boostRecent(e){return e.sort((t,r)=>{let i=this.history.getScore(t.command.id),n=this.history.getScore(r.command.id);return i&&!n?-1:n&&!i?1:i&&n?n-i:r.score-t.score})}_setActiveIndex(e){let t=this.state.activeIndex;if(this.state.activeIndex=e,this.elements){let i=this.elements.list.querySelector(`[data-index="${t}"]`),n=this.elements.list.querySelector(`[data-index="${e}"]`);i&&(i.classList.remove("bw-cmd-item--active"),i.setAttribute("aria-selected","false")),n&&(n.classList.add("bw-cmd-item--active"),n.setAttribute("aria-selected","true")),O(this.elements.list,e),this.elements.input.setAttribute("aria-activedescendant",`bw-cmd-item-${e}`)}let r=this.state.results[e]?.command||null;this._emit("navigate",{index:e,command:r})}_selectActive(){let e=this.state.results[this.state.activeIndex];!e||e.command.disabled||this._selectCommand(e.command)}_selectCommand(e){if(e.children&&e.children.length>0){this.state.breadcrumb.push(e),this._performSearch(""),this.elements&&(this.elements.input.value="",this.elements.input.focus(),w(this.elements.breadcrumb,this.state.breadcrumb,t=>this._navigateToBreadcrumb(t)));return}this.options.rememberRecent&&this.history.add(e.id),e.action?.(e),this._emit("select",e),this.options.onSelect?.(e),this.options.closeOnSelect&&this.close()}_navigateBack(){this.state.breadcrumb.length!==0&&(this.state.breadcrumb.pop(),this._performSearch(""),this.elements&&w(this.elements.breadcrumb,this.state.breadcrumb,e=>this._navigateToBreadcrumb(e)))}_navigateToBreadcrumb(e){this.state.breadcrumb=this.state.breadcrumb.slice(0,e+1),this._performSearch(""),this.elements&&(this.elements.input.value="",this.elements.input.focus(),w(this.elements.breadcrumb,this.state.breadcrumb,t=>this._navigateToBreadcrumb(t)))}_updateUI(){if(!this.elements)return;let{results:e,activeIndex:t,isLoading:r}=this.state;D(this.elements,{isEmpty:e.length===0,isLoading:r,activeIndex:t}),!r&&e.length>0&&N(this.elements.list,e,t,{onSelect:i=>this._selectCommand(i),onHover:i=>this._setActiveIndex(i)})}_emit(e,t){let r=this.eventListeners.get(e);r&&r.forEach(i=>i(t))}};export{_ as BWCommandPalette,A as createRecentHistory,E as formatHotkey,v as fuzzyMatch,x as highlightMatches,C as matchesHotkey,y as parseHotkey,k as searchCommands};