ext(affordances): wireHostnameChip with debounced URL parse

This commit is contained in:
adlee-was-taken
2026-05-01 18:06:15 -04:00
parent 8eff96da9d
commit 61dbb4d3a3
4 changed files with 159 additions and 1 deletions

View File

@@ -1362,3 +1362,26 @@ textarea {
opacity: 0.4;
cursor: not-allowed;
}
.hostname-chip-row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
font-size: 11px;
color: var(--text-muted);
}
.hostname-chip {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 3px;
font-size: 10px;
font-weight: 600;
color: #0c1118;
}
.hostname-text {
font-family: ui-monospace, monospace;
}

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { wireFillFromTab } from '../url-tools';
import { wireFillFromTab, wireHostnameChip } from '../url-tools';
describe('wireFillFromTab', () => {
let form: HTMLElement;
@@ -44,3 +44,60 @@ describe('wireFillFromTab', () => {
expect((form.querySelector('#fill-from-tab-btn') as HTMLButtonElement).disabled).toBe(true);
});
});
describe('wireHostnameChip', () => {
let form: HTMLElement;
beforeEach(() => {
form = document.createElement('div');
form.innerHTML = `
<div class="form-group">
<input id="f-url" type="text" />
<div id="hostname-chip-row" class="hostname-chip-row" hidden></div>
</div>
`;
document.body.appendChild(form);
vi.useFakeTimers();
});
it('renders chip + hostname on valid URL after debounce', () => {
wireHostnameChip(form);
const input = form.querySelector('#f-url') as HTMLInputElement;
input.value = 'https://github.com/login';
input.dispatchEvent(new Event('input'));
vi.advanceTimersByTime(250);
const row = form.querySelector('#hostname-chip-row') as HTMLElement;
expect(row.hidden).toBe(false);
expect(row.textContent).toContain('github.com');
expect(row.querySelector('.hostname-chip')?.textContent).toBe('G');
});
it('hides chip if URL is empty', () => {
wireHostnameChip(form);
const input = form.querySelector('#f-url') as HTMLInputElement;
input.value = '';
input.dispatchEvent(new Event('input'));
vi.advanceTimersByTime(250);
expect((form.querySelector('#hostname-chip-row') as HTMLElement).hidden).toBe(true);
});
it('hides chip if URL does not parse', () => {
wireHostnameChip(form);
const input = form.querySelector('#f-url') as HTMLInputElement;
input.value = '!!!not-a-url';
input.dispatchEvent(new Event('input'));
vi.advanceTimersByTime(250);
expect((form.querySelector('#hostname-chip-row') as HTMLElement).hidden).toBe(true);
});
it('treats scheme-less host as https://', () => {
wireHostnameChip(form);
const input = form.querySelector('#f-url') as HTMLInputElement;
input.value = 'gitlab.com/users/sign_in';
input.dispatchEvent(new Event('input'));
vi.advanceTimersByTime(250);
const row = form.querySelector('#hostname-chip-row') as HTMLElement;
expect(row.hidden).toBe(false);
expect(row.textContent).toContain('gitlab.com');
});
});

View File

@@ -22,3 +22,58 @@ export function wireFillFromTab(form: HTMLElement, opts: FillFromTabOpts): void
}
export const FILL_FROM_TAB_BTN_HTML = `<button id="fill-from-tab-btn" class="glyph-btn" type="button" title="fill from active tab">${GLYPH_FILL_FROM_TAB}</button>`;
const CHIP_HUES = [
'#5ea0c4', '#c47e5e', '#5ec47a', '#c45e9c',
'#a3c45e', '#7e5ec4', '#c4b75e', '#5ec4c4',
];
function hostnameHue(host: string): string {
let h = 0;
for (let i = 0; i < host.length; i++) h = (h * 31 + host.charCodeAt(i)) | 0;
return CHIP_HUES[Math.abs(h) % CHIP_HUES.length];
}
function tryParseHost(raw: string): string | null {
const trimmed = raw.trim();
if (!trimmed) return null;
const candidate = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed) ? trimmed : `https://${trimmed}`;
try {
const u = new URL(candidate);
const host = u.host || null;
if (!host) return null;
// Validate hostname contains only valid characters
if (!/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/.test(host)) {
return null;
}
return host;
} catch {
return null;
}
}
export function wireHostnameChip(form: HTMLElement): void {
const input = form.querySelector<HTMLInputElement>('#f-url');
const row = form.querySelector<HTMLElement>('#hostname-chip-row');
if (!input || !row) return;
let timer: ReturnType<typeof setTimeout> | null = null;
const update = () => {
const host = tryParseHost(input.value);
if (!host) {
row.hidden = true;
row.innerHTML = '';
return;
}
const initial = host[0]?.toUpperCase() ?? '?';
const hue = hostnameHue(host);
row.hidden = false;
row.innerHTML = `<span class="hostname-chip" style="background:${hue};">${initial}</span><span class="hostname-text">${host}</span>`;
};
input.addEventListener('input', () => {
if (timer !== null) clearTimeout(timer);
timer = setTimeout(() => { timer = null; update(); }, 200);
});
update(); // initial render for prefilled values
}

View File

@@ -1392,3 +1392,26 @@ textarea {
opacity: 0.4;
cursor: not-allowed;
}
.hostname-chip-row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
font-size: 11px;
color: var(--text-muted);
}
.hostname-chip {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 3px;
font-size: 10px;
font-weight: 600;
color: #0c1118;
}
.hostname-text {
font-family: ui-monospace, monospace;
}