@ta-interaktiv/react-municipality-search 1.9.3 → 1.11.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.
@@ -1,12 +1,7 @@
1
1
  /* eslint-disable @typescript-eslint/camelcase */
2
2
  import React, { ChangeEvent, Component } from 'react'
3
- import '@ta-interaktiv/semantic-ui/semantic/dist/components/icon.css'
4
- import '@ta-interaktiv/semantic-ui/semantic/dist/components/input.css'
5
- import '@ta-interaktiv/semantic-ui/semantic/dist/components/label.css'
6
- // import './styles.scss'
7
- import styled from 'styled-components'
3
+ import styled, { css } from 'styled-components'
8
4
  import { animated, config as springConfig, Transition } from '@react-spring/web'
9
- import IntlMessageFormat from 'intl-messageformat'
10
5
  import de from './locales/de.yml'
11
6
  import fr from './locales/fr.yml'
12
7
  import Cookies from 'js-cookie'
@@ -24,13 +19,14 @@ interface LocalisedMessagesList {
24
19
 
25
20
  export interface Municipality {
26
21
  readonly GDENR: number
27
- readonly ORTNAME: string
28
- readonly PLZ4: number
22
+ readonly ORTNAME?: string
23
+ readonly PLZ4?: number
29
24
  readonly PLZ6?: number
30
- readonly GDENAMK: string
25
+ readonly GDENAMK?: string
31
26
  readonly KTKZ: string
32
- readonly NORMORTSNAME: string
27
+ readonly NORMORTSNAME?: string
33
28
  readonly NORMGEMEINDE: string
29
+ readonly URL?: string
34
30
  timestamp?: number
35
31
  }
36
32
 
@@ -42,9 +38,6 @@ export interface Props {
42
38
  /* Deduplicate list of municipality names */
43
39
  dedupe?: boolean
44
40
 
45
- /* Hide the tooltip */
46
- hideTooltip?: boolean
47
-
48
41
  /* Reset the component when the user selects a municipality */
49
42
  resetOnSelect?: boolean
50
43
 
@@ -71,6 +64,18 @@ export interface Props {
71
64
 
72
65
  /** Custom list of municipalities */
73
66
  customMunicipalities?: Municipality[]
67
+
68
+ /** currently selected municipality displayed inside the input field */
69
+ selectedMunicipality?: Municipality
70
+
71
+ /** handler for when close button pressed */
72
+ onCloseHandler?: () => void
73
+
74
+ /** input background color */
75
+ inputBackgroundColor?: string
76
+
77
+ /** result background color */
78
+ resultBackgroundColor?: string
74
79
  }
75
80
 
76
81
  interface State {
@@ -99,7 +104,7 @@ const localStorageItemName = 'selectedMunicipalities'
99
104
  */
100
105
  const translationServiceFactory = (
101
106
  messageList: LocalisedMessagesList,
102
- locale: string
107
+ locale: string,
103
108
  ) => {
104
109
  return (key: string | null) => {
105
110
  if (key == null) {
@@ -110,7 +115,7 @@ const translationServiceFactory = (
110
115
  .split('.')
111
116
  .reduce(
112
117
  (messages: MessageListContent, prop: string) => messages[prop],
113
- messageList[locale]
118
+ messageList[locale],
114
119
  ) || 'TRANSLATED STRING NOT FOUND'
115
120
  )
116
121
  }
@@ -152,6 +157,8 @@ export class MunicipalitySearch extends Component<Props, State> {
152
157
  municipalityData: '2021v3',
153
158
  locale: 'de',
154
159
  numberOfLastSelectedMunicipalities: 1,
160
+ resultBackgroundColor: 'var(--site-background)',
161
+ inputBackgroundColor: 'var(--site-background)',
155
162
  }
156
163
 
157
164
  public state = {
@@ -183,7 +190,7 @@ export class MunicipalitySearch extends Component<Props, State> {
183
190
  const digits = digitsResultsArray[0]
184
191
  results = municipalities.filter(
185
192
  (municipality: Municipality) =>
186
- municipality.PLZ4.toString() === digits
193
+ (municipality.PLZ4 ?? '').toString() === digits,
187
194
  )
188
195
 
189
196
  if (results.length < 1) {
@@ -193,7 +200,7 @@ export class MunicipalitySearch extends Component<Props, State> {
193
200
  // in case it starts with a number, we're assuming the reader is looking
194
201
  // for a zip code
195
202
  results = municipalities.filter((municipality: Municipality) =>
196
- RegExp(`^${searchTerm}`).test(municipality.PLZ4.toString())
203
+ RegExp(`^${searchTerm}`).test((municipality.PLZ4 ?? '').toString()),
197
204
  )
198
205
 
199
206
  if (results.length < 1) {
@@ -209,10 +216,10 @@ export class MunicipalitySearch extends Component<Props, State> {
209
216
  results = municipalities
210
217
  .filter(
211
218
  (municipality: Municipality) =>
212
- r.test(municipality.ORTNAME) ||
213
- r.test(municipality.GDENAMK) ||
214
- r.test(municipality.NORMORTSNAME) ||
215
- r.test(municipality.NORMGEMEINDE)
219
+ r.test(municipality.ORTNAME ?? '') ||
220
+ r.test(municipality.GDENAMK ?? '') ||
221
+ r.test(municipality.NORMORTSNAME ?? '') ||
222
+ r.test(municipality.NORMGEMEINDE),
216
223
  )
217
224
  .slice(0, this.props.maxResults)
218
225
 
@@ -234,14 +241,14 @@ export class MunicipalitySearch extends Component<Props, State> {
234
241
 
235
242
  private removeDuplicates(
236
243
  arr: Municipality[],
237
- searchTerm: string
244
+ searchTerm: string,
238
245
  ): Municipality[] {
239
246
  const map = new Map()
240
247
  arr.forEach((v) => {
241
248
  if (this.props.dedupe) {
242
- map.set(v.ORTNAME + v.GDENR, v)
249
+ map.set((v.ORTNAME ?? '') + v.GDENR, v)
243
250
  } else {
244
- map.set(v.PLZ4 + v.ORTNAME + v.GDENR, v)
251
+ map.set((v.PLZ4 ?? '') + (v.ORTNAME ?? '') + v.GDENR, v)
245
252
  }
246
253
  })
247
254
 
@@ -250,8 +257,8 @@ export class MunicipalitySearch extends Component<Props, State> {
250
257
  .reduce(
251
258
  (acc, cur) => {
252
259
  if (
253
- cur.GDENAMK.toLowerCase() === searchTerm.toLowerCase() ||
254
- cur.ORTNAME.toLowerCase() === searchTerm.toLowerCase()
260
+ cur.GDENAMK?.toLowerCase() === searchTerm.toLowerCase() ||
261
+ cur.ORTNAME?.toLowerCase() === searchTerm.toLowerCase()
255
262
  ) {
256
263
  acc[0].push(cur)
257
264
  } else {
@@ -259,7 +266,7 @@ export class MunicipalitySearch extends Component<Props, State> {
259
266
  }
260
267
  return acc
261
268
  },
262
- [[], []]
269
+ [[], []],
263
270
  )
264
271
  .flat()
265
272
 
@@ -340,7 +347,7 @@ export class MunicipalitySearch extends Component<Props, State> {
340
347
  return -1
341
348
  }
342
349
  return 0
343
- }
350
+ },
344
351
  )
345
352
  return this.deduplicateMunicipalityArray(ordered)
346
353
  }
@@ -355,7 +362,7 @@ export class MunicipalitySearch extends Component<Props, State> {
355
362
  // limit the number of stored municipalities
356
363
  const slicedArray = prevSelectedMunicipalities.slice(
357
364
  0,
358
- 15
365
+ 15,
359
366
  ) as Municipality[]
360
367
  newMuni.timestamp = Date.now()
361
368
  slicedArray.unshift(newMuni)
@@ -364,7 +371,7 @@ export class MunicipalitySearch extends Component<Props, State> {
364
371
  try {
365
372
  // deduplicate the array, sort it by timestamp and stringify it for localStorage and cookies
366
373
  const newMuniArray = JSON.stringify(
367
- this.deduplicateMunicipalityArray(slicedArray)
374
+ this.deduplicateMunicipalityArray(slicedArray),
368
375
  )
369
376
  localStorage.setItem(localStorageItemName, newMuniArray)
370
377
  Cookies.set(localStorageItemName, newMuniArray, {
@@ -383,7 +390,7 @@ export class MunicipalitySearch extends Component<Props, State> {
383
390
  */
384
391
 
385
392
  private deduplicateMunicipalityArray = (
386
- munis: Municipality[]
393
+ munis: Municipality[],
387
394
  ): Municipality[] => {
388
395
  return [
389
396
  ...new Map(
@@ -391,9 +398,11 @@ export class MunicipalitySearch extends Component<Props, State> {
391
398
  .slice()
392
399
  .reverse()
393
400
  .map((muni: Municipality) => [
394
- muni.ORTNAME + muni.GDENR + (this.props.dedupe ? '' : muni.PLZ4),
401
+ (muni.ORTNAME ?? '') +
402
+ muni.GDENR +
403
+ (this.props.dedupe ? '' : muni.PLZ4),
395
404
  muni,
396
- ])
405
+ ]),
397
406
  ).values(),
398
407
  ]
399
408
  .reverse()
@@ -430,7 +439,7 @@ export class MunicipalitySearch extends Component<Props, State> {
430
439
  .then((res) => {
431
440
  if (!res.ok) {
432
441
  throw new MunicipalityDownloadError(
433
- `Download error: ${res.status}: ${res.statusText}.`
442
+ `Download error: ${res.status}: ${res.statusText}.`,
434
443
  )
435
444
  }
436
445
  return res.json()
@@ -447,10 +456,10 @@ export class MunicipalitySearch extends Component<Props, State> {
447
456
  if (error instanceof MunicipalityDownloadError) {
448
457
  console.log(error)
449
458
  console.info(
450
- 'Make sure that you have the municipality and zip code data for the required year uploaded to the Interaktiv server.'
459
+ 'Make sure that you have the municipality and zip code data for the required year uploaded to the Interaktiv server.',
451
460
  )
452
461
  console.info(
453
- 'Read more about the data generation in the "data" directory in the project repository for the MunicipalitySearch React component.'
462
+ 'Read more about the data generation in the "data" directory in the project repository for the MunicipalitySearch React component.',
454
463
  )
455
464
  this.setState({
456
465
  isLoading: false,
@@ -482,29 +491,31 @@ export class MunicipalitySearch extends Component<Props, State> {
482
491
  prevSelectedMunicipalities.length > 0
483
492
  ) {
484
493
  const allBfsIdsAndNames = prevSelectedMunicipalities.map(
485
- (muni) => muni.GDENR + muni.ORTNAME + muni.PLZ4
494
+ (muni) => muni.GDENR + muni.ORTNAME + muni.PLZ4,
486
495
  )
487
496
  const filteredFromFetchedData: Municipality[] = data.filter(
488
497
  (muni: Municipality) =>
489
- allBfsIdsAndNames.includes(muni.GDENR + muni.ORTNAME + muni.PLZ4)
498
+ allBfsIdsAndNames.includes(
499
+ muni.GDENR + (muni.ORTNAME ?? '') + muni.PLZ4,
500
+ ),
490
501
  )
491
502
  // sort before deduplication
492
503
  filteredFromFetchedData.sort((a, b) => {
493
504
  return (
494
- allBfsIdsAndNames.indexOf(a.GDENR + a.ORTNAME + a.PLZ4) -
495
- allBfsIdsAndNames.indexOf(b.GDENR + b.ORTNAME + b.PLZ4)
505
+ allBfsIdsAndNames.indexOf(a.GDENR + (a.ORTNAME ?? '') + a.PLZ4) -
506
+ allBfsIdsAndNames.indexOf(b.GDENR + (b.ORTNAME ?? '') + b.PLZ4)
496
507
  )
497
508
  })
498
509
  // always dedupe bc. sometimes there is duplicate data in the jsons
499
510
  const dedupedData = this.deduplicateMunicipalityArray(
500
- filteredFromFetchedData
511
+ filteredFromFetchedData,
501
512
  )
502
513
 
503
514
  // sort the dedupedData in the same order as in the localStorage
504
515
  dedupedData.sort((a, b) => {
505
516
  return (
506
- allBfsIdsAndNames.indexOf(a.GDENR + a.ORTNAME + a.PLZ4) -
507
- allBfsIdsAndNames.indexOf(b.GDENR + b.ORTNAME + b.PLZ4)
517
+ allBfsIdsAndNames.indexOf(a.GDENR + (a.ORTNAME ?? '') + a.PLZ4) -
518
+ allBfsIdsAndNames.indexOf(b.GDENR + (b.ORTNAME ?? '') + b.PLZ4)
508
519
  )
509
520
  })
510
521
  const muniCount = this.props.numberOfLastSelectedMunicipalities
@@ -524,52 +535,47 @@ export class MunicipalitySearch extends Component<Props, State> {
524
535
  */
525
536
  const t = translationServiceFactory(MESSAGES, this.props.locale)
526
537
 
527
- // result string formatter
528
- const resultString = new IntlMessageFormat(
529
- MESSAGES[this.props.locale].results,
530
- this.props.locale + '-CH'
531
- )
532
-
533
- const labelContent = () => {
534
- if (this.state.error) {
535
- return t(this.state.error)
536
- }
537
-
538
- if (!!value && value.length >= 2) {
539
- return resultString.format({ numResults: results.length })
540
- }
541
-
542
- return t('label')
543
- }
544
-
545
- const labelDirection = window.matchMedia('screen and (max-width: 599px)')
546
- .matches
547
- ? 'top pointing'
548
- : 'left pointing'
549
-
550
538
  return (
551
539
  <MunicipalitySearchContainer className='municipality-search'>
552
540
  <InputRow className='inputRow'>
553
- <div
554
- className={
555
- 'ui' +
556
- (this.props?.iconOnRightSide ? ' right ' : ' left ') +
557
- 'icon input'
541
+ <FlexInput
542
+ // id='search'
543
+ type='text'
544
+ // className='prompt flexInput'
545
+ lessPaddingLeft={
546
+ !!this.props.selectedMunicipality || this.props.iconOnRightSide
558
547
  }
559
- >
560
- <FlexInput
561
- id='search'
562
- type='text'
563
- className='prompt flexInput'
564
- placeholder={
565
- this.props.placeholder || (t('placeholder') as string)
566
- }
567
- value={value}
568
- disabled={
569
- this.state.error === 'error.municipalitiesNotDownloaded'
570
- }
571
- onChange={this.handleSearchChange}
572
- />
548
+ placeholder={this.props.placeholder || (t('placeholder') as string)}
549
+ value={this.props.selectedMunicipality?.NORMGEMEINDE ?? value}
550
+ disabled={
551
+ this.state.error === 'error.municipalitiesNotDownloaded' ||
552
+ !!this.props.selectedMunicipality
553
+ }
554
+ onChange={this.handleSearchChange}
555
+ backgroundColor={this.props.inputBackgroundColor}
556
+ />
557
+ {this.props.selectedMunicipality ? (
558
+ <Icon
559
+ onClick={() => this.props.onCloseHandler?.()}
560
+ isClickable
561
+ id='search-closing-icon'
562
+ isRight
563
+ >
564
+ <svg
565
+ width='20'
566
+ height='21'
567
+ viewBox='0 0 24 25'
568
+ xmlns='http://www.w3.org/2000/svg'
569
+ fill='currentColor'
570
+ strokeWidth="2"
571
+ >
572
+ <path
573
+ d='M3.9 22L2 20.1L9.6 12.5L2 4.9L3.9 3L11.5 10.6L19.1 3L21 4.9L13.4 12.5L21 20.1L19.1 22L11.5 14.4L3.9 22Z'
574
+ fill='currentColor'
575
+ />
576
+ </svg>
577
+ </Icon>
578
+ ) : (
573
579
  <Icon isRight={this.props?.iconOnRightSide}>
574
580
  <svg
575
581
  width='18'
@@ -577,35 +583,28 @@ export class MunicipalitySearch extends Component<Props, State> {
577
583
  viewBox='0 0 18 18'
578
584
  fill='none'
579
585
  xmlns='http://www.w3.org/2000/svg'
580
- stroke='black'
581
- strokeWidth='3'
586
+ stroke='currentColor'
587
+ strokeWidth='2'
582
588
  >
583
589
  <circle cx='7.5' cy='7.5' r='6' />
584
590
  <line x1='11' y1='11' x2='17' y2='17' />
585
591
  </svg>
586
592
  </Icon>
587
- </div>
588
- {!this.props.hideTooltip && (
589
- <div>
590
- <label
591
- htmlFor='search'
592
- className={`ui ${
593
- this.state.error ? 'red' : 'basic grey'
594
- } ${labelDirection} label`}
595
- >
596
- {String(labelContent())}
597
- </label>
598
- </div>
599
593
  )}
600
594
  </InputRow>
601
595
  {showResults(results) && (
602
- <Results className='results'>
596
+ <Results
597
+ className='results'
598
+ backgroundColor={this.props.resultBackgroundColor}
599
+ >
603
600
  {showResults(results) && (
604
601
  <Transition
605
602
  native
606
603
  items={results}
607
604
  keys={(result: Municipality) =>
608
- result.PLZ4 + result.GDENR + result.ORTNAME
605
+ result.GDENR +
606
+ String(result.PLZ4 ?? '') +
607
+ (result.ORTNAME ?? '')
609
608
  }
610
609
  from={{ transform: 'translate(0,-20px)', opacity: 0 }}
611
610
  enter={{ transform: 'translate(0,0px)', opacity: 1 }}
@@ -643,7 +642,18 @@ export class MunicipalitySearch extends Component<Props, State> {
643
642
  </ResultHeader>
644
643
  <ResultMeta className='resultMeta'>
645
644
  <>
646
- {t('list.municipalityPrefix')} <b>{result.GDENAMK}</b>
645
+ {result.ORTNAME ? t('list.municipalityPrefix') : ''}{' '}
646
+ <b>
647
+ {result.GDENAMK ??
648
+ result.NORMGEMEINDE +
649
+ (results.find(
650
+ (muni: Municipality) =>
651
+ muni !== result &&
652
+ muni.NORMGEMEINDE === result.NORMGEMEINDE,
653
+ )
654
+ ? ' (' + result.KTKZ + ')'
655
+ : '')}
656
+ </b>
647
657
  </>
648
658
  </ResultMeta>
649
659
  </animated.div>
@@ -658,7 +668,8 @@ export class MunicipalitySearch extends Component<Props, State> {
658
668
 
659
669
  // endregion
660
670
  }
661
- const Icon = styled.i<{ isRight?: boolean }>`
671
+ const Icon = styled.i<{ isRight?: boolean; isClickable?: boolean }>`
672
+ cursor: ${(props) => (props.isClickable ? 'pointer' : 'default')};
662
673
  position: absolute;
663
674
  top: 50%;
664
675
  ${(props) => (props.isRight ? 'right: 0; padding-right: 0.8em;' : '')}
@@ -667,9 +678,43 @@ const Icon = styled.i<{ isRight?: boolean }>`
667
678
  display: flex;
668
679
  opacity: 0.5;
669
680
  transition: opacity 0.5s ease-in-out;
681
+ animation: fromLeft 0.3s ease-in-out;
682
+ @keyframes fromLeft {
683
+ 0% {
684
+ transform: translateY(-50%) translateX(-10px);
685
+ opacity: 0;
686
+ }
687
+ 100% {
688
+ transform: translateY(-50%) translateX(0);
689
+ }
690
+ }
691
+ &#search-closing-icon {
692
+ &:hover {
693
+ opacity: 1;
694
+ }
695
+ animation: fromRight 0.3s ease-in-out;
696
+ @keyframes fromRight {
697
+ 0% {
698
+ transform: translateY(-50%) translateX(10px);
699
+ opacity: 0;
700
+ }
701
+ 100% {
702
+ transform: translateY(-50%) translateX(0);
703
+ }
704
+ }
705
+ }
706
+ `
707
+ const MunicipalitySearchContainer = styled.div`
708
+ --border-radius: 0.3em;
709
+ font-size: 18px;
710
+ @media screen and (max-width: 599px) {
711
+ max-width: 100%;
712
+ }
713
+ max-width: 300px;
714
+ color: inherit;
670
715
  `
671
- const MunicipalitySearchContainer = styled.div``
672
716
  const InputRow = styled.div`
717
+ position: relative;
673
718
  display: flex;
674
719
  flex-direction: row;
675
720
  align-items: center;
@@ -678,36 +723,69 @@ const InputRow = styled.div`
678
723
  }
679
724
  @media screen and (max-width: 599px) {
680
725
  flex-direction: column;
681
- .ui.input {
682
- width: 100%;
683
- max-width: 100%;
684
- }
726
+ }
727
+ .ui.input {
728
+ width: 100%;
729
+ max-width: 100%;
730
+ transition: all 0.5s ease-in-out;
685
731
  }
686
732
  `
687
- const FlexInput = styled.input`
733
+ const FlexInput = styled.input<{
734
+ lessPaddingLeft?: boolean
735
+ backgroundColor?: string
736
+ }>`
737
+ // reset the default input styles
738
+ color: inherit;
739
+ background-color: ${({ backgroundColor }) => backgroundColor};
740
+ border: solid 1px #7e7e7e7e;
741
+ border-radius: var(--border-radius);
742
+ box-shadow: none;
743
+ font-size: 1em;
744
+ outline: none;
745
+ transition: all 0.3s ease-in-out;
746
+ padding: 0.5em;
747
+ padding-left: ${({ lessPaddingLeft }) => (lessPaddingLeft ? '1em' : '2.4em')};
748
+ margin: 0;
749
+ width: 100%;
688
750
  display: flex;
751
+ &::placeholder{
752
+ color: inherit;
753
+ opacity: 0.5;
754
+ }
689
755
  `
690
- const Results = styled.div`
691
- margin-top: 1ex;
756
+ const Results = styled.div<{ backgroundColor?: string }>`
757
+ margin-top: .25em;
692
758
  display: grid;
693
- grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
759
+ grid-template-columns: 1fr;
694
760
  grid-gap: 0;
695
- font-family: var(--ui-font-stack);
761
+ font-family: var(--font-plex);
762
+ overflow: hidden;
763
+ display: flex;
764
+ flex-direction: column;
765
+ pointer-events: auto;
766
+ position: absolute;
767
+ background-color: ${({ backgroundColor }) => backgroundColor};
768
+ width: 100%;
769
+ border-radius: var(--border-radius);
770
+ @media screen and (min-width: 599px) {
771
+ max-width: 300px;
772
+ }
696
773
  .result {
774
+ max-width: 100%;
775
+ color: currentColor;
697
776
  padding: calc(11 / 16 * 1em) calc(14 / 16 * 1em);
698
- border-radius: 0.2ex;
777
+ border-radius: var(--border-radius);
699
778
  box-shadow: 0 0 0 rgba(0, 0, 0, 0.3);
700
779
  //border: 1px solid #007abf;
701
- color: rgba(0, 0, 0, 0.9);
780
+ color: inherit;
702
781
  line-height: 1.1em;
703
782
  cursor: pointer;
704
783
  transition: box-shadow 200ms ease-in-out;
705
- background-color: transparent;
706
-
707
784
  &:hover,
708
785
  &:focus {
709
- box-shadow: 0 0 0.5ex rgba(0, 0, 0, 0.2), 0 0 0 1px #007abf inset;
710
- background-color: $backgroundColor;
786
+ box-shadow:
787
+ 0 0 0.5ex rgba(0, 0, 0, 0.2),
788
+ 0 0 0 1px #007abf inset;
711
789
  }
712
790
  }
713
791
  `