feat(web): add InputBar and PropertyPills components
InputBar provides fixed-to-bottom text input with Enter to submit, blur-delay (150ms) for pill interaction, and cursor-aware text insertion. PropertyPills shows 8 modifier pills (Due, Pri, Project, Tag, Recur, Scheduled, Wait, Until) on input focus. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
<script>
|
||||
import PropertyPills from './PropertyPills.svelte';
|
||||
|
||||
/**
|
||||
* @type {(input: string) => void}
|
||||
*/
|
||||
export let onSubmit;
|
||||
|
||||
export let error = '';
|
||||
export let loading = false;
|
||||
|
||||
let value = '';
|
||||
let focused = false;
|
||||
|
||||
/** @type {HTMLInputElement|null} */
|
||||
let inputEl = null;
|
||||
|
||||
/** @type {ReturnType<typeof setTimeout>|null} */
|
||||
let blurTimer = null;
|
||||
|
||||
function handleSubmit() {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || loading) return;
|
||||
onSubmit(trimmed);
|
||||
value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} e
|
||||
*/
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
if (blurTimer) {
|
||||
clearTimeout(blurTimer);
|
||||
blurTimer = null;
|
||||
}
|
||||
focused = true;
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
blurTimer = setTimeout(() => {
|
||||
focused = false;
|
||||
blurTimer = null;
|
||||
}, 150);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert text at cursor position
|
||||
* @param {string} text
|
||||
*/
|
||||
function insertAtCursor(text) {
|
||||
if (!inputEl) return;
|
||||
const start = inputEl.selectionStart ?? value.length;
|
||||
const end = inputEl.selectionEnd ?? value.length;
|
||||
|
||||
// Add leading space if cursor isn't at start and prev char isn't a space
|
||||
const needsSpace = start > 0 && value[start - 1] !== ' ';
|
||||
const insert = (needsSpace ? ' ' : '') + text;
|
||||
|
||||
value = value.slice(0, start) + insert + value.slice(end);
|
||||
|
||||
// Restore focus and cursor position after the inserted text
|
||||
const newPos = start + insert.length;
|
||||
requestAnimationFrame(() => {
|
||||
if (inputEl) {
|
||||
inputEl.focus();
|
||||
inputEl.setSelectionRange(newPos, newPos);
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="input-bar">
|
||||
<PropertyPills visible={focused} onInsert={insertAtCursor} />
|
||||
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="input-row">
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
bind:value
|
||||
on:keydown={handleKeydown}
|
||||
on:focus={handleFocus}
|
||||
on:blur={handleBlur}
|
||||
type="text"
|
||||
placeholder="Add task... (e.g. Buy milk due:tomorrow priority:H)"
|
||||
disabled={loading}
|
||||
class="input"
|
||||
/>
|
||||
<button
|
||||
class="submit-btn"
|
||||
on:click={handleSubmit}
|
||||
disabled={!value.trim() || loading}
|
||||
type="button"
|
||||
aria-label="Add task"
|
||||
>
|
||||
{#if loading}
|
||||
<span class="loading"></span>
|
||||
{:else}
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.input-bar {
|
||||
flex-shrink: 0;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
padding-bottom: calc(var(--spacing-sm) + env(safe-area-inset-bottom, 0px));
|
||||
background-color: var(--bg-primary);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
font-size: var(--font-size-base);
|
||||
font-family: inherit;
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.15);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
min-width: 44px;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
background-color: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: var(--spacing-xs) 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script>
|
||||
/**
|
||||
* @type {(text: string) => void}
|
||||
*/
|
||||
export let onInsert;
|
||||
|
||||
export let visible = false;
|
||||
|
||||
const pills = [
|
||||
{ label: 'Due', text: 'due:' },
|
||||
{ label: 'Pri', text: 'priority:' },
|
||||
{ label: 'Project', text: 'project:' },
|
||||
{ label: 'Tag', text: '+' },
|
||||
{ label: 'Recur', text: 'recur:' },
|
||||
{ label: 'Scheduled', text: 'scheduled:' },
|
||||
{ label: 'Wait', text: 'wait:' },
|
||||
{ label: 'Until', text: 'until:' }
|
||||
];
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div class="pills">
|
||||
{#each pills as pill}
|
||||
<button
|
||||
class="pill"
|
||||
type="button"
|
||||
on:mousedown|preventDefault={() => onInsert(pill.text)}
|
||||
>
|
||||
{pill.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-xs) 0;
|
||||
}
|
||||
|
||||
.pill {
|
||||
padding: 0.25rem 0.625rem;
|
||||
background-color: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 1rem;
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: inherit;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
min-height: 28px;
|
||||
min-width: unset;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.pill:hover {
|
||||
background-color: var(--border-color);
|
||||
}
|
||||
|
||||
.pill:active {
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user