@reuters-graphics/graphics-components 3.3.3 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,303 @@
1
+ <!-- @component `Geocoder` [Read the docs.](https://reuters-graphics.github.io/graphics-components/?path=/docs/components-controls-geocoder--docs) -->
2
+ <script lang="ts">
3
+ import { onDestroy } from 'svelte';
4
+ import { geocode, type GeocodeFeature, type GeocodeOptions } from './geocode';
5
+ import MagnifyingGlass from '../SearchInput/components/MagnifyingGlass.svelte';
6
+ import X from '../SearchInput/components/X.svelte';
7
+
8
+ interface Props extends Omit<GeocodeOptions, 'accessToken'> {
9
+ /** Mapbox public access token. */
10
+ accessToken: string;
11
+ /** Placeholder text shown in the search input. */
12
+ searchPlaceholder?: string;
13
+ /** Callback fired when a location is selected from the results. */
14
+ onselect?: (location: { lng: number; lat: number; name: string }) => void;
15
+ }
16
+
17
+ let {
18
+ accessToken,
19
+ searchPlaceholder = 'Search for a location',
20
+ onselect,
21
+ autocomplete = true,
22
+ bbox,
23
+ country,
24
+ language = ['en'],
25
+ limit = 5,
26
+ proximity,
27
+ types,
28
+ worldview,
29
+ permanent,
30
+ entrances,
31
+ }: Props = $props();
32
+
33
+ let query = $state('');
34
+ let suggestions: GeocodeFeature[] = $state([]);
35
+ let selectedIndex = $state(-1);
36
+ let focused = $state(false);
37
+
38
+ let showDropdown = $derived(suggestions.length > 0 && focused);
39
+
40
+ const instanceId = Math.random().toString(36).slice(2, 9);
41
+ const listboxId = `geocoder-listbox-${instanceId}`;
42
+
43
+ let inputEl: HTMLInputElement;
44
+ let debounceTimer: ReturnType<typeof setTimeout>;
45
+ let abortController: AbortController | null = null;
46
+
47
+ function handleInput() {
48
+ selectedIndex = -1;
49
+ clearTimeout(debounceTimer);
50
+ abortController?.abort();
51
+
52
+ if (query.length < 2) {
53
+ suggestions = [];
54
+ return;
55
+ }
56
+
57
+ debounceTimer = setTimeout(async () => {
58
+ abortController = new AbortController();
59
+ try {
60
+ suggestions = await geocode(
61
+ query,
62
+ {
63
+ accessToken,
64
+ autocomplete,
65
+ bbox,
66
+ country,
67
+ language,
68
+ limit,
69
+ proximity,
70
+ types,
71
+ worldview,
72
+ permanent,
73
+ entrances,
74
+ },
75
+ abortController.signal
76
+ );
77
+ } catch (e) {
78
+ if (e instanceof DOMException && e.name === 'AbortError') return;
79
+ console.error('Geocoder error:', e);
80
+ }
81
+ }, 300);
82
+ }
83
+
84
+ onDestroy(() => {
85
+ clearTimeout(debounceTimer);
86
+ abortController?.abort();
87
+ });
88
+
89
+ let active = $derived(query !== '');
90
+ let statusMessage = $derived(
91
+ showDropdown ?
92
+ `${suggestions.length} result${suggestions.length === 1 ? '' : 's'} available`
93
+ : ''
94
+ );
95
+
96
+ function selectSuggestion(feature: GeocodeFeature) {
97
+ const { longitude: lng, latitude: lat } = feature.properties.coordinates;
98
+ query = feature.properties.name;
99
+ suggestions = [];
100
+ onselect?.({ lng, lat, name: feature.properties.name });
101
+ }
102
+
103
+ function clear() {
104
+ query = '';
105
+ suggestions = [];
106
+ inputEl?.focus();
107
+ }
108
+
109
+ function handleKeydown(e: KeyboardEvent) {
110
+ if (!showDropdown) return;
111
+
112
+ if (e.key === 'ArrowDown') {
113
+ e.preventDefault();
114
+ selectedIndex = (selectedIndex + 1) % suggestions.length;
115
+ } else if (e.key === 'ArrowUp') {
116
+ e.preventDefault();
117
+ selectedIndex =
118
+ selectedIndex <= 0 ? suggestions.length - 1 : selectedIndex - 1;
119
+ } else if (e.key === 'Enter') {
120
+ e.preventDefault();
121
+ if (selectedIndex >= 0) selectSuggestion(suggestions[selectedIndex]);
122
+ } else if (e.key === 'Escape') {
123
+ e.preventDefault();
124
+ suggestions = [];
125
+ selectedIndex = -1;
126
+ }
127
+ }
128
+ </script>
129
+
130
+ <div class="geocoder-input-wrapper">
131
+ <div class="geocoder-icon">
132
+ <MagnifyingGlass />
133
+ </div>
134
+ <input
135
+ bind:this={inputEl}
136
+ bind:value={query}
137
+ oninput={handleInput}
138
+ onkeydown={handleKeydown}
139
+ onfocus={() => (focused = true)}
140
+ onblur={() => (focused = false)}
141
+ type="text"
142
+ autocapitalize="words"
143
+ autocomplete="off"
144
+ enterkeyhint="search"
145
+ spellcheck="false"
146
+ role="combobox"
147
+ aria-expanded={showDropdown}
148
+ aria-label={searchPlaceholder}
149
+ aria-controls={showDropdown ? listboxId : undefined}
150
+ aria-activedescendant={selectedIndex >= 0 ?
151
+ `${listboxId}-option-${selectedIndex}`
152
+ : undefined}
153
+ aria-autocomplete="list"
154
+ placeholder={searchPlaceholder}
155
+ class="geocoder-input"
156
+ />
157
+ {#if active}
158
+ <button
159
+ class="geocoder-clear"
160
+ type="button"
161
+ aria-label="Clear search"
162
+ onmousedown={(e) => {
163
+ e.preventDefault();
164
+ clear();
165
+ }}
166
+ >
167
+ <X />
168
+ </button>
169
+ {/if}
170
+ <div class="sr-only" role="status" aria-live="polite" aria-atomic="true">
171
+ {statusMessage}
172
+ </div>
173
+ {#if showDropdown}
174
+ <ul class="geocoder-results" role="listbox" id={listboxId}>
175
+ {#each suggestions as suggestion, i}
176
+ <li
177
+ id="{listboxId}-option-{i}"
178
+ role="option"
179
+ aria-selected={i === selectedIndex}
180
+ class:active={i === selectedIndex}
181
+ onmousedown={(e) => {
182
+ e.preventDefault();
183
+ selectSuggestion(suggestion);
184
+ }}
185
+ onmouseenter={() => (selectedIndex = i)}
186
+ >
187
+ {suggestion.properties.full_address || suggestion.properties.name}
188
+ </li>
189
+ {/each}
190
+ </ul>
191
+ {/if}
192
+ </div>
193
+
194
+ <style>/* Generated from
195
+ https://utopia.fyi/space/calculator/?c=320,18,1.125,1280,21,1.25,7,3,&s=0.75|0.5|0.25,1.5|2|3|4|6,s-l&g=s,l,xl,12
196
+ */
197
+ /* Generated from
198
+ https://utopia.fyi/space/calculator/?c=320,18,1.125,1280,21,1.25,7,3,&s=0.75|0.5|0.25,1.5|2|3|4|6,s-l&g=s,l,xl,12
199
+ */
200
+ /* Scales by 1.125 */
201
+ .geocoder-input-wrapper {
202
+ position: relative;
203
+ width: 100%;
204
+ }
205
+
206
+ .geocoder-icon {
207
+ position: absolute;
208
+ top: 50%;
209
+ left: 12px;
210
+ transform: translateY(-50%);
211
+ width: 22px;
212
+ height: 22px;
213
+ pointer-events: none;
214
+ display: flex;
215
+ align-items: center;
216
+ justify-content: center;
217
+ }
218
+
219
+ .geocoder-clear {
220
+ position: absolute;
221
+ top: 50%;
222
+ right: 10px;
223
+ transform: translateY(-50%);
224
+ width: 22px;
225
+ height: 22px;
226
+ padding: 0;
227
+ border: none;
228
+ background: none;
229
+ cursor: pointer;
230
+ display: flex;
231
+ align-items: center;
232
+ justify-content: center;
233
+ }
234
+
235
+ .geocoder-input {
236
+ font-family: var(--theme-font-family-sans-serif);
237
+ font-size: var(--theme-font-size-sm);
238
+ width: 100%;
239
+ height: 46px;
240
+ line-height: 22px;
241
+ padding: 12px 36px 12px 42px;
242
+ border: 1px solid var(--theme-colour-brand-rules);
243
+ border-radius: 0.25rem;
244
+ background: var(--theme-colour-background);
245
+ box-sizing: border-box;
246
+ }
247
+ .geocoder-input::placeholder {
248
+ color: var(--theme-colour-text-secondary);
249
+ }
250
+
251
+ .geocoder-results {
252
+ position: absolute;
253
+ top: 100%;
254
+ left: 0;
255
+ right: 0;
256
+ margin: 4px 0 0;
257
+ padding: 0;
258
+ list-style: none;
259
+ background: var(--theme-colour-background);
260
+ border-radius: 0.25rem;
261
+ box-shadow: 0 2px 6px var(--theme-colour-brand-shadow);
262
+ z-index: 10000;
263
+ overflow: hidden;
264
+ }
265
+
266
+ .geocoder-results li {
267
+ padding: 10px 14px;
268
+ cursor: pointer;
269
+ font-family: var(--theme-font-family-sans-serif);
270
+ font-size: var(--theme-font-size-xs);
271
+ white-space: nowrap;
272
+ overflow: hidden;
273
+ text-overflow: ellipsis;
274
+ }
275
+ .geocoder-results li.active {
276
+ background-color: var(--tr-hover-background-grey);
277
+ }
278
+
279
+ .sr-only {
280
+ position: absolute;
281
+ width: 1px;
282
+ height: 1px;
283
+ padding: 0;
284
+ margin: -1px;
285
+ overflow: hidden;
286
+ clip: rect(0, 0, 0, 0);
287
+ white-space: nowrap;
288
+ border: 0;
289
+ }
290
+
291
+ @media (max-width: 767px) {
292
+ .geocoder-icon {
293
+ left: 10px;
294
+ width: 20px;
295
+ height: 20px;
296
+ }
297
+ .geocoder-input {
298
+ height: 42px;
299
+ font-size: var(--theme-font-size-xs);
300
+ padding: 10px 12px 10px 38px;
301
+ border-radius: 0.25rem;
302
+ }
303
+ }</style>
@@ -0,0 +1,17 @@
1
+ import { type GeocodeOptions } from './geocode';
2
+ interface Props extends Omit<GeocodeOptions, 'accessToken'> {
3
+ /** Mapbox public access token. */
4
+ accessToken: string;
5
+ /** Placeholder text shown in the search input. */
6
+ searchPlaceholder?: string;
7
+ /** Callback fired when a location is selected from the results. */
8
+ onselect?: (location: {
9
+ lng: number;
10
+ lat: number;
11
+ name: string;
12
+ }) => void;
13
+ }
14
+ /** `Geocoder` [Read the docs.](https://reuters-graphics.github.io/graphics-components/?path=/docs/components-controls-geocoder--docs) */
15
+ declare const Geocoder: import("svelte").Component<Props, {}, "">;
16
+ type Geocoder = ReturnType<typeof Geocoder>;
17
+ export default Geocoder;
@@ -0,0 +1,50 @@
1
+ export type GeocodeFeatureType = 'country' | 'region' | 'postcode' | 'district' | 'place' | 'locality' | 'neighborhood' | 'street' | 'address';
2
+ export interface GeocodeOptions {
3
+ /** Mapbox public access token. */
4
+ accessToken: string;
5
+ /** Return partial prefix matches (true) or exact matches only (false). Defaults to true. */
6
+ autocomplete?: boolean;
7
+ /** Limit results to a bounding box: [minLon, minLat, maxLon, maxLat]. Cannot cross the 180th meridian. */
8
+ bbox?: [number, number, number, number];
9
+ /** Filter results to one or more countries using ISO 3166-1 alpha-2 codes. */
10
+ country?: string[];
11
+ /** IETF language tags for the response. Also influences result scoring. Max 20. */
12
+ language?: string[];
13
+ /** Maximum number of results to return (1–10). Defaults to 5. */
14
+ limit?: number;
15
+ /** Bias results toward a location: [lon, lat] coordinates or 'ip' to use the request IP. */
16
+ proximity?: [number, number] | 'ip';
17
+ /** Filter results by feature type. */
18
+ types?: GeocodeFeatureType[];
19
+ /** Geopolitical worldview for boundary representation (e.g. 'us', 'cn', 'in'). Defaults to 'us'. */
20
+ worldview?: string;
21
+ /** Set to true if results will be stored/cached permanently. Defaults to false. */
22
+ permanent?: boolean;
23
+ /** Return building entrance data when available (public preview). Defaults to false. */
24
+ entrances?: boolean;
25
+ }
26
+ export interface GeocodeFeature {
27
+ type: 'Feature';
28
+ properties: {
29
+ mapbox_id: string;
30
+ feature_type: GeocodeFeatureType;
31
+ name: string;
32
+ name_preferred?: string;
33
+ place_formatted?: string;
34
+ full_address?: string;
35
+ coordinates: {
36
+ longitude: number;
37
+ latitude: number;
38
+ };
39
+ context: Record<string, {
40
+ mapbox_id: string;
41
+ name: string;
42
+ [key: string]: unknown;
43
+ }>;
44
+ };
45
+ geometry: {
46
+ type: 'Point';
47
+ coordinates: [number, number];
48
+ };
49
+ }
50
+ export declare function geocode(query: string, options: GeocodeOptions, signal?: AbortSignal): Promise<GeocodeFeature[]>;
@@ -0,0 +1,34 @@
1
+ const BASE_URL = 'https://api.mapbox.com/search/geocode/v6/forward';
2
+ export async function geocode(query, options, signal) {
3
+ const params = new URLSearchParams({
4
+ q: query,
5
+ access_token: options.accessToken,
6
+ });
7
+ if (options.autocomplete !== undefined)
8
+ params.set('autocomplete', String(options.autocomplete));
9
+ if (options.bbox)
10
+ params.set('bbox', options.bbox.join(','));
11
+ if (options.country)
12
+ params.set('country', options.country.join(','));
13
+ if (options.language)
14
+ params.set('language', options.language.join(','));
15
+ if (options.limit !== undefined)
16
+ params.set('limit', String(options.limit));
17
+ if (options.proximity)
18
+ params.set('proximity', Array.isArray(options.proximity) ?
19
+ options.proximity.join(',')
20
+ : options.proximity);
21
+ if (options.types)
22
+ params.set('types', options.types.join(','));
23
+ if (options.worldview)
24
+ params.set('worldview', options.worldview);
25
+ if (options.permanent !== undefined)
26
+ params.set('permanent', String(options.permanent));
27
+ if (options.entrances !== undefined)
28
+ params.set('entrances', String(options.entrances));
29
+ const res = await fetch(`${BASE_URL}?${params}`, { signal });
30
+ if (!res.ok)
31
+ throw new Error(`Geocode request failed: ${res.status}`);
32
+ const data = await res.json();
33
+ return data.features ?? [];
34
+ }
@@ -0,0 +1,91 @@
1
+ <!-- @component `LanguageButton` [Read the docs.](https://reuters-graphics.github.io/graphics-components/?path=/docs/components-text-elements-languagebutton--docs) -->
2
+ <script lang="ts">
3
+ interface Props {
4
+ /** The current locale of the article */
5
+ locale: string | undefined;
6
+ /** Whether the article is embedded */
7
+ embedded?: boolean;
8
+ /** The base URL for the article */
9
+ base?: string;
10
+ /** Options for the language toggle button */
11
+ buttonOptions?: {
12
+ locale: string;
13
+ label: string;
14
+ };
15
+ /** Custom function for handling URL changes for locales and embed versions. */
16
+ setUrl?: () => string;
17
+ }
18
+
19
+ let {
20
+ locale = 'en',
21
+ embedded = false,
22
+ base,
23
+ buttonOptions = {
24
+ locale: 'es',
25
+ label: 'Leer en español',
26
+ },
27
+ setUrl,
28
+ }: Props = $props();
29
+
30
+ let translationLocale = buttonOptions.locale;
31
+
32
+ const handleLocale = () => {
33
+ if (setUrl) {
34
+ return setUrl();
35
+ }
36
+
37
+ if (embedded) {
38
+ if (locale === translationLocale) {
39
+ // If we're in the non-English article, link to the English article
40
+ return `${base}/embeds/en/page/`;
41
+ } else {
42
+ return `${base}/embeds/${translationLocale}/page`;
43
+ }
44
+ } else {
45
+ if (locale === translationLocale) {
46
+ // If we're in the non-English article, link to the English article
47
+ return `${base}/`;
48
+ } else {
49
+ return `${base}/${translationLocale}`;
50
+ }
51
+ }
52
+ };
53
+ </script>
54
+
55
+ <div class="language-button">
56
+ <a data-sveltekit-reload href={handleLocale()}
57
+ ><button
58
+ id="translate-button"
59
+ class="text-sm"
60
+ aria-label="Toggle article language"
61
+ >{locale === translationLocale ? 'Read in English' : (
62
+ buttonOptions.label
63
+ )}</button
64
+ ></a
65
+ >
66
+ </div>
67
+
68
+ <style>div {
69
+ display: flex;
70
+ justify-content: center;
71
+ }
72
+ div #translate-button {
73
+ position: absolute;
74
+ font-weight: 500;
75
+ text-transform: uppercase;
76
+ letter-spacing: 0.06rem;
77
+ font-size: var(--theme-font-size-xs);
78
+ background-color: #d3e1e1;
79
+ color: rgb(91, 101, 101);
80
+ padding: 2px 12px;
81
+ border-radius: 4px;
82
+ z-index: 10;
83
+ cursor: pointer;
84
+ position: relative;
85
+ border: none;
86
+ transition: background-color 0.25s ease, color 0.25s ease;
87
+ }
88
+ div #translate-button:hover {
89
+ background-color: #889d9b;
90
+ color: #ebf7f7;
91
+ }</style>
@@ -0,0 +1,19 @@
1
+ interface Props {
2
+ /** The current locale of the article */
3
+ locale: string | undefined;
4
+ /** Whether the article is embedded */
5
+ embedded?: boolean;
6
+ /** The base URL for the article */
7
+ base?: string;
8
+ /** Options for the language toggle button */
9
+ buttonOptions?: {
10
+ locale: string;
11
+ label: string;
12
+ };
13
+ /** Custom function for handling URL changes for locales and embed versions. */
14
+ setUrl?: () => string;
15
+ }
16
+ /** `LanguageButton` [Read the docs.](https://reuters-graphics.github.io/graphics-components/?path=/docs/components-text-elements-languagebutton--docs) */
17
+ declare const LanguageButton: import("svelte").Component<Props, {}, "">;
18
+ type LanguageButton = ReturnType<typeof LanguageButton>;
19
+ export default LanguageButton;
package/dist/index.d.ts CHANGED
@@ -16,6 +16,8 @@ export { default as DocumentCloud } from './components/DocumentCloud/DocumentClo
16
16
  export { default as EmbedPreviewerLink } from './components/EmbedPreviewerLink/EmbedPreviewerLink.svelte';
17
17
  export { default as FeaturePhoto } from './components/FeaturePhoto/FeaturePhoto.svelte';
18
18
  export { default as Framer } from './components/Framer/Framer.svelte';
19
+ export { default as Geocoder } from './components/Geocoder/Geocoder.svelte';
20
+ export { geocode } from './components/Geocoder/geocode';
19
21
  export { default as GraphicBlock } from './components/GraphicBlock/GraphicBlock.svelte';
20
22
  export { default as Headline } from './components/Headline/Headline.svelte';
21
23
  export { default as Headpile } from './components/Headpile/Headpile.svelte';
@@ -25,6 +27,7 @@ export { default as EndNotes } from './components/EndNotes/EndNotes.svelte';
25
27
  export { default as InfoBox } from './components/InfoBox/InfoBox.svelte';
26
28
  export { default as InlineAd } from './components/AdSlot/InlineAd.svelte';
27
29
  export { default as KinesisLogo } from './components/KinesisLogo/KinesisLogo.svelte';
30
+ export { default as LanguageButton } from './components/LanguageButton/LanguageButton.svelte';
28
31
  export { default as LeaderboardAd } from './components/AdSlot/LeaderboardAd.svelte';
29
32
  export { default as TileMap } from './components/TileMap/TileMap.svelte';
30
33
  export { default as TileMapLayer } from './components/TileMap/TileMapLayer.svelte';
@@ -55,3 +58,4 @@ export { default as ToolsHeader } from './components/ToolsHeader/ToolsHeader.sve
55
58
  export { default as Video } from './components/Video/Video.svelte';
56
59
  export { default as Visible } from './components/Visible/Visible.svelte';
57
60
  export type { ContainerWidth, HeadlineSize, ScrollerVideoInstance, } from './components/@types/global';
61
+ export type { GeocodeOptions, GeocodeFeature, GeocodeFeatureType, } from './components/Geocoder/geocode';
package/dist/index.js CHANGED
@@ -19,6 +19,8 @@ export { default as DocumentCloud } from './components/DocumentCloud/DocumentClo
19
19
  export { default as EmbedPreviewerLink } from './components/EmbedPreviewerLink/EmbedPreviewerLink.svelte';
20
20
  export { default as FeaturePhoto } from './components/FeaturePhoto/FeaturePhoto.svelte';
21
21
  export { default as Framer } from './components/Framer/Framer.svelte';
22
+ export { default as Geocoder } from './components/Geocoder/Geocoder.svelte';
23
+ export { geocode } from './components/Geocoder/geocode';
22
24
  export { default as GraphicBlock } from './components/GraphicBlock/GraphicBlock.svelte';
23
25
  export { default as Headline } from './components/Headline/Headline.svelte';
24
26
  export { default as Headpile } from './components/Headpile/Headpile.svelte';
@@ -28,6 +30,7 @@ export { default as EndNotes } from './components/EndNotes/EndNotes.svelte';
28
30
  export { default as InfoBox } from './components/InfoBox/InfoBox.svelte';
29
31
  export { default as InlineAd } from './components/AdSlot/InlineAd.svelte';
30
32
  export { default as KinesisLogo } from './components/KinesisLogo/KinesisLogo.svelte';
33
+ export { default as LanguageButton } from './components/LanguageButton/LanguageButton.svelte';
31
34
  export { default as LeaderboardAd } from './components/AdSlot/LeaderboardAd.svelte';
32
35
  export { default as TileMap } from './components/TileMap/TileMap.svelte';
33
36
  export { default as TileMapLayer } from './components/TileMap/TileMapLayer.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reuters-graphics/graphics-components",
3
- "version": "3.3.3",
3
+ "version": "3.4.0",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "homepage": "https://reuters-graphics.github.io/graphics-components",