WIP PWA for Grain
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add grain-input atom and refactor explore page

- Create reusable grain-input component with clearable prop
- Use solid search icon (no outline variant available)
- Refactor explore page to use grain-input
- Auto-focus input on page load
- Use consistent 6px border-radius

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+120 -47
+1 -1
src/components/atoms/grain-icon.js
··· 14 14 logout: 'fa-solid fa-right-from-bracket', 15 15 plus: 'fa-solid fa-plus', 16 16 search: 'fa-solid fa-magnifying-glass', 17 - searchLine: 'fa-regular fa-magnifying-glass', 17 + searchLine: 'fa-solid fa-magnifying-glass', 18 18 ellipsis: 'fa-solid fa-ellipsis', 19 19 ellipsisVertical: 'fa-solid fa-ellipsis-vertical', 20 20 download: 'fa-solid fa-download',
+105
src/components/atoms/grain-input.js
··· 1 + import { LitElement, html, css } from 'lit'; 2 + 3 + export class GrainInput extends LitElement { 4 + static properties = { 5 + type: { type: String }, 6 + placeholder: { type: String }, 7 + value: { type: String }, 8 + clearable: { type: Boolean } 9 + }; 10 + 11 + static styles = css` 12 + :host { 13 + display: block; 14 + } 15 + .wrapper { 16 + position: relative; 17 + } 18 + input { 19 + width: 100%; 20 + padding: var(--space-sm); 21 + border: 1px solid var(--color-border); 22 + border-radius: 6px; 23 + background: var(--color-bg-primary); 24 + color: var(--color-text-primary); 25 + font-size: var(--font-size-sm); 26 + font-family: inherit; 27 + box-sizing: border-box; 28 + } 29 + :host([clearable]) input { 30 + padding-right: 36px; 31 + } 32 + input::placeholder { 33 + color: var(--color-text-secondary); 34 + } 35 + input:focus { 36 + outline: none; 37 + border-color: var(--color-text-secondary); 38 + } 39 + .clear-btn { 40 + position: absolute; 41 + right: 10px; 42 + top: 50%; 43 + transform: translateY(-50%); 44 + background: none; 45 + border: none; 46 + color: var(--color-text-secondary); 47 + cursor: pointer; 48 + padding: 4px; 49 + font-size: 16px; 50 + line-height: 1; 51 + } 52 + `; 53 + 54 + constructor() { 55 + super(); 56 + this.type = 'text'; 57 + this.placeholder = ''; 58 + this.value = ''; 59 + this.clearable = false; 60 + } 61 + 62 + #handleInput(e) { 63 + this.value = e.target.value; 64 + this.dispatchEvent(new CustomEvent('input', { 65 + detail: { value: this.value }, 66 + bubbles: true, 67 + composed: true 68 + })); 69 + } 70 + 71 + #handleClear() { 72 + this.value = ''; 73 + this.dispatchEvent(new CustomEvent('input', { 74 + detail: { value: '' }, 75 + bubbles: true, 76 + composed: true 77 + })); 78 + this.dispatchEvent(new CustomEvent('clear', { 79 + bubbles: true, 80 + composed: true 81 + })); 82 + } 83 + 84 + focus() { 85 + this.shadowRoot.querySelector('input')?.focus(); 86 + } 87 + 88 + render() { 89 + return html` 90 + <div class="wrapper"> 91 + <input 92 + type=${this.type} 93 + placeholder=${this.placeholder} 94 + .value=${this.value} 95 + @input=${this.#handleInput} 96 + > 97 + ${this.clearable && this.value ? html` 98 + <button class="clear-btn" @click=${this.#handleClear}>&times;</button> 99 + ` : ''} 100 + </div> 101 + `; 102 + } 103 + } 104 + 105 + customElements.define('grain-input', GrainInput);
+14 -46
src/components/pages/grain-explore.js
··· 4 4 import '../organisms/grain-gallery-card.js'; 5 5 import '../molecules/grain-profile-card.js'; 6 6 import '../atoms/grain-spinner.js'; 7 + import '../atoms/grain-input.js'; 7 8 8 9 export class GrainExplore extends LitElement { 9 10 static properties = { ··· 28 29 z-index: 10; 29 30 border-bottom: 1px solid var(--color-border); 30 31 } 31 - .search-input-wrapper { 32 - position: relative; 32 + .search-container grain-input { 33 33 max-width: var(--feed-max-width); 34 34 margin: 0 auto; 35 35 } 36 - input { 37 - width: 100%; 38 - padding: var(--space-sm) var(--space-md); 39 - padding-right: 40px; 40 - border: 1px solid var(--color-border); 41 - border-radius: 20px; 42 - background: var(--color-bg-secondary); 43 - color: var(--color-text-primary); 44 - font-size: var(--font-size-sm); 45 - box-sizing: border-box; 46 - } 47 - input::placeholder { 48 - color: var(--color-text-secondary); 49 - } 50 - input:focus { 51 - outline: none; 52 - border-color: var(--color-text-secondary); 53 - } 54 - .clear-btn { 55 - position: absolute; 56 - right: 12px; 57 - top: 50%; 58 - transform: translateY(-50%); 59 - background: none; 60 - border: none; 61 - color: var(--color-text-secondary); 62 - cursor: pointer; 63 - padding: 4px; 64 - font-size: 16px; 65 - } 66 36 .tabs { 67 37 display: flex; 68 38 max-width: var(--feed-max-width); ··· 81 51 } 82 52 .tab.active { 83 53 color: var(--color-text-primary); 84 - font-weight: var(--font-weight-semibold); 85 54 border-bottom-color: var(--color-text-primary); 86 55 } 87 56 .empty { ··· 119 88 if (this.#debounceTimer) clearTimeout(this.#debounceTimer); 120 89 } 121 90 122 - firstUpdated() { 91 + async firstUpdated() { 123 92 this.#setupInfiniteScroll(); 93 + const input = this.shadowRoot.querySelector('grain-input'); 94 + await input?.updateComplete; 95 + input?.focus(); 124 96 } 125 97 126 98 #handleInput(e) { 127 - const value = e.target.value; 99 + const value = e.detail?.value ?? e.target.value; 128 100 this._query = value; 129 101 130 102 if (this.#debounceTimer) clearTimeout(this.#debounceTimer); ··· 266 238 render() { 267 239 return html` 268 240 <div class="search-container"> 269 - <div class="search-input-wrapper"> 270 - <input 271 - type="text" 272 - placeholder="Search for galleries or users" 273 - .value=${this._query} 274 - @input=${this.#handleInput} 275 - > 276 - ${this._query ? html` 277 - <button class="clear-btn" @click=${this.#handleClear}>&times;</button> 278 - ` : ''} 279 - </div> 241 + <grain-input 242 + placeholder="Search for galleries or users" 243 + .value=${this._query} 244 + clearable 245 + @input=${this.#handleInput} 246 + @clear=${this.#handleClear} 247 + ></grain-input> 280 248 </div> 281 249 282 250 <div class="tabs">