Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- "Load from URL" tab allowing users to input a GTFS feed URL directly
- `UrlInput` component exported for standalone use

## [0.2.2] - 2026-03-09

### Changed
Expand Down
29 changes: 13 additions & 16 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions src/components/GtfsSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { transportDataGouvFr } from '../sources/transport-data-gouv-fr';
import { mobilityDataCsv } from '../sources/mobility-data-csv';
import { DropZone } from './DropZone';
import { SourceSearch } from './SourceSearch';
import { UrlInput } from './UrlInput';

export interface GtfsSelectorProps {
/** Called when a GTFS source is selected (file dropped or URL picked) */
Expand Down Expand Up @@ -67,6 +68,14 @@ export function GtfsSelector({
>
Import file
</button>
<button
className={`${cls('rgs-selector__tab')} ${activeTab === 'url' ? cls('rgs-selector__tab--active') : ''}`}
role="tab"
aria-selected={activeTab === 'url'}
onClick={() => setActiveTab('url')}
>
Load from URL
</button>
{allSources.map((source) => (
<button
key={source.id}
Expand All @@ -83,6 +92,8 @@ export function GtfsSelector({
<div className={cls('rgs-selector__panel')} role="tabpanel">
{activeTab === 'file' ? (
<DropZone onFile={handleFile} />
) : activeTab === 'url' ? (
<UrlInput onSelect={onSelect} className={cls('rgs-url-input')} />
) : (
(() => {
const source = allSources.find((s) => s.id === activeTab);
Expand Down
58 changes: 58 additions & 0 deletions src/components/UrlInput.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { UrlInput } from './UrlInput';

describe('UrlInput', () => {
it('renders input and button', () => {
render(<UrlInput onSelect={vi.fn()} />);

expect(screen.getByTestId('url-input')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Load' })).toBeInTheDocument();
});

it('calls onSelect with url result on submit', async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(<UrlInput onSelect={onSelect} />);

await user.type(screen.getByTestId('url-input'), 'https://example.com/gtfs.zip');
await user.click(screen.getByRole('button', { name: 'Load' }));

expect(onSelect).toHaveBeenCalledWith({
type: 'url',
url: 'https://example.com/gtfs.zip',
title: 'https://example.com/gtfs.zip',
});
});

it('does not call onSelect when input is empty', async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(<UrlInput onSelect={onSelect} />);

await user.click(screen.getByRole('button', { name: 'Load' }));

expect(onSelect).not.toHaveBeenCalled();
});

it('trims whitespace from URL', async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(<UrlInput onSelect={onSelect} />);

await user.type(screen.getByTestId('url-input'), ' https://example.com/feed ');
await user.click(screen.getByRole('button', { name: 'Load' }));

expect(onSelect).toHaveBeenCalledWith({
type: 'url',
url: 'https://example.com/feed',
title: 'https://example.com/feed',
});
});

it('applies className prop', () => {
const { container } = render(<UrlInput onSelect={vi.fn()} className="my-class" />);
expect(container.querySelector('form')?.className).toBe('my-class');
});
});
35 changes: 35 additions & 0 deletions src/components/UrlInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useState, useCallback } from 'react';
import type { GtfsSelectionResult } from '../types';

export interface UrlInputProps {
onSelect: (result: GtfsSelectionResult) => void;
className?: string;
}

export function UrlInput({ onSelect, className }: UrlInputProps) {
const [url, setUrl] = useState('');

const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
const trimmed = url.trim();
if (!trimmed) return;
onSelect({ type: 'url', url: trimmed, title: trimmed });
},
[url, onSelect],
);

return (
<form className={className} onSubmit={handleSubmit}>
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com/gtfs.zip"
aria-label="GTFS feed URL"
data-testid="url-input"
/>
<button type="submit">Load</button>
</form>
);
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export { GtfsSelector } from './components/GtfsSelector';
export type { GtfsSelectorProps } from './components/GtfsSelector';
export { DropZone } from './components/DropZone';
export { UrlInput } from './components/UrlInput';
export type { UrlInputProps } from './components/UrlInput';
export { SourceSearch } from './components/SourceSearch';

export type { GtfsSelectionResult, GtfsSearchResult, GtfsSource } from './types';
Expand Down
37 changes: 37 additions & 0 deletions src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,43 @@
color: #d32f2f;
}

/* URL input */
.rgs-url-input {
display: flex;
gap: 8px;
}

.rgs-url-input input {
flex: 1;
padding: 10px 12px;
font-size: 14px;
border: 1px solid #ccc;
border-radius: 6px;
outline: none;
box-sizing: border-box;
transition: border-color 0.15s;
}

.rgs-url-input input:focus {
border-color: #0066cc;
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.15);
}

.rgs-url-input button {
padding: 10px 20px;
font-size: 14px;
border: none;
border-radius: 6px;
background: #0066cc;
color: #fff;
cursor: pointer;
transition: background 0.15s;
}

.rgs-url-input button:hover {
background: #0052a3;
}

/* Source search */
.rgs-source-search {
position: relative;
Expand Down
Loading