ext(affordances): wireHostnameChip with debounced URL parse
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user