@autobusal/common 1.4.1 → 1.4.3

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.
package/Avatar.tsx CHANGED
@@ -8,7 +8,7 @@ interface Props {
8
8
 
9
9
  const Avatar = ({ display }: Props): JSX.Element => (
10
10
  <Container>
11
- { display?.logo_url !== '' && <img src={ display?.logo_url } />}
11
+ { display?.logo_url !== '' && <img src={ display?.logo_url } alt={ display?.company } />}
12
12
  { display?.company }
13
13
  </Container>
14
14
  );
package/CHANGELOG.md ADDED
@@ -0,0 +1,64 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@autobusal/common` are documented here. This project follows
4
+ [Keep a Changelog](https://keepachangelog.com/) and [Semantic Versioning](https://semver.org/).
5
+
6
+ ## [1.4.3] - 2026-07-19
7
+
8
+ ### Changed
9
+
10
+ - **`Meta` (`Meta.tsx`) is now brand-configurable, eliminating the per-brand SEO fork.**
11
+ Merged magus's rich SEO `Meta` (robots, canonical, Open Graph and Twitter card tags, plus
12
+ optional `image`/`url`/`type`/`noIndex` props) into the shared component, and replaced the
13
+ hard-coded "BusMagus" strings with a `VITE_APP_NAME` build-time env: the `<title>` suffix,
14
+ `og:title`/`twitter:title` and `og:site_name` derive from that brand name, and when it is
15
+ unset the title renders bare (no suffix) with `og:site_name` omitted — so a brand that has
16
+ not configured a name is unaffected. This lets every whitelabel consume the published
17
+ `Meta` and pass its own brand via env, instead of magus vite-aliasing `@autobusal/common`
18
+ to a local `src/modules/Common` copy just to override this one file. `children` is now
19
+ optional; the prop set is a superset of the previous one, so all existing `<Meta title=…>`
20
+ call sites are unaffected.
21
+
22
+ ## [1.4.2] - 2026-07-19
23
+
24
+ ### Added
25
+
26
+ - **Cross-field validation + masked pre-fill for `Password` (`Password/Password.tsx`).** Added
27
+ an optional `getValues` prop (react-hook-form's `UseFormGetValues`) that is threaded into
28
+ `Validate(...)`, enabling cross-field rules like `matches:password` (e.g. a
29
+ password-confirmation field). Added an optional `defaultValue` prop applied to the input so
30
+ an admin edit form can render a saved secret as masked dots instead of blank, so editing a
31
+ different field no longer wipes the stored password on save.
32
+ - **Keyboard accessibility for the calendar day picker (`Calendar/Days.tsx`,
33
+ `Calendar/styles.ts`).** Day cells now expose `role="button"`, `tabIndex` (`-1` when
34
+ disabled), `aria-disabled`, `aria-pressed`, and an `onKeyDown` handler that activates on
35
+ Enter/Space, plus a `:focus-visible` outline. Days are now selectable via keyboard, not
36
+ only mouse.
37
+ - **Debounced type-to-filter search (`Table/Top/Search.tsx`).** The table search input now
38
+ submits automatically ~500ms after the user stops typing (via a `useEffect`/`setTimeout`
39
+ with an initial-render guard using `useRef`), instead of requiring an Enter press that
40
+ nothing on screen advertised. Explicit Enter submit is preserved.
41
+
42
+ ### Changed
43
+
44
+ - **`Password` pre-fill wired through the dynamic viewer (`Viewer/Data.tsx`).** The `password`
45
+ field case now passes `defaultValue={ value.string }` to `Password`, so saved password
46
+ values hydrate as masked input in generated forms.
47
+ - **Confirm-password persistence of language selection (`ChangeLanguage/ChangeLanguage.tsx`).**
48
+ Language changes are now persisted to `localStorage['language']` so the chosen language
49
+ survives reloads rather than resetting to the default.
50
+ - **Explicit `common` namespace on i18n lookups (`Gender.tsx`, `Seats/ChooseSeat.tsx`).**
51
+ Translation calls now pass `{ ns: 'common' }` (gender male/female labels; seat
52
+ chosen/choose labels) so the correct namespace resolves and the raw key is no longer shown
53
+ when the component is used outside the default namespace context.
54
+ - **Styled Calendar browse dropdown (`Calendar/Browse/styles.ts`).** The select now has a
55
+ visible border, border-radius, padding, pointer cursor, and a custom chevron background,
56
+ replacing `border: none` so it reads as an interactive control.
57
+
58
+ ### Fixed
59
+
60
+ - **Missing image alt text (`Avatar.tsx`, `CompanyItem/CompanyItem.tsx`, `RouteItem/RouteItem.tsx`).**
61
+ Operator/company logo `<img>` tags now include `alt={ ...company }`, giving screen readers a
62
+ meaningful label and a fallback when the logo fails to load.
63
+
64
+ Authored by Ferjolt Ozuni. Consolidated from the magus and alvavel whitelabel patch sets into canonical @autobusal source (eliminates per-repo patch-package divergence).
@@ -30,5 +30,11 @@ export const Select = styled.select<{$type?: 'year'}>`
30
30
  `}
31
31
  text-align: center;
32
32
  background-color: ${ props => props.theme.background.neutral };
33
- border: none;
33
+ border: 1px solid ${ props => props.theme.inputs.border };
34
+ border-radius: ${ props => props.theme.borderRadius };
35
+ padding: 0 22px 0 8px;
36
+ cursor: pointer;
37
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23888' stroke-width='1.5'/%3E%3C/svg%3E");
38
+ background-repeat: no-repeat;
39
+ background-position: right 6px center;
34
40
  `;
package/Calendar/Days.tsx CHANGED
@@ -81,6 +81,16 @@ const Days = ({ type, value, selected, onSelected }: Props): JSX.Element => {
81
81
  $disabled={ disabled }
82
82
  $selected={ selected === currentValue }
83
83
  onClick={ () => onSelected(currentValue, disabled) }
84
+ role="button"
85
+ tabIndex={ disabled ? -1 : 0 }
86
+ aria-disabled={ disabled }
87
+ aria-pressed={ selected === currentValue }
88
+ onKeyDown={ event => {
89
+ if (event.key === 'Enter' || event.key === ' ') {
90
+ event.preventDefault();
91
+ onSelected(currentValue, disabled);
92
+ }
93
+ } }
84
94
  >
85
95
  <span>{ currentDay }</span>
86
96
  </Day>
@@ -66,4 +66,9 @@ export const Day = styled.div<{
66
66
  &:hover > span {
67
67
  background-color: ${ props => props.theme.background.neutral };
68
68
  }
69
+
70
+ &:focus-visible {
71
+ outline: 2px solid ${ props => props.theme.primary.normal };
72
+ outline-offset: 2px;
73
+ }
69
74
  `;
@@ -21,6 +21,7 @@ const ChangeLanguage = ({ type, t, i18n, onClose }: Props): JSX.Element => {
21
21
 
22
22
  const onChange = (language: string): void => {
23
23
  i18n.changeLanguage(language);
24
+ localStorage.setItem('language', language);
24
25
  onClose();
25
26
  };
26
27
 
@@ -16,12 +16,12 @@ const CompanyItem = ({ item, type }: Props): JSX.Element => {
16
16
  { type === 'operator'
17
17
  ? (
18
18
  <LinkLogo to={ item.link }>
19
- <img src={ item.logo_url } />
19
+ <img src={ item.logo_url } alt={ item.company } />
20
20
  </LinkLogo>
21
21
  )
22
22
  : (
23
23
  <ContainerLogo>
24
- <img src={ item.logo_url } />
24
+ <img src={ item.logo_url } alt={ item.company } />
25
25
  </ContainerLogo>
26
26
  ) }
27
27
 
package/Gender.tsx CHANGED
@@ -19,7 +19,7 @@ const Gender = ({ name, defaultValue, t, refs }: Props) => (
19
19
  { ...refs(name) }
20
20
  />
21
21
 
22
- { t('data.gender.male') }
22
+ { t('data.gender.male', { ns: 'common' }) }
23
23
  </label>
24
24
 
25
25
  <label>
@@ -30,7 +30,7 @@ const Gender = ({ name, defaultValue, t, refs }: Props) => (
30
30
  { ...refs(name) }
31
31
  />
32
32
 
33
- { t('data.gender.female') }
33
+ { t('data.gender.female', { ns: 'common' }) }
34
34
  </label>
35
35
  </div>
36
36
  </div>
package/Meta.tsx CHANGED
@@ -4,20 +4,53 @@ interface Props {
4
4
  title: string
5
5
  keywords?: string
6
6
  description?: string
7
- children: any
7
+ image?: string
8
+ url?: string
9
+ type?: string
10
+ noIndex?: boolean
11
+ children?: any
8
12
  }
9
13
 
10
- const Meta = ({ title, keywords, description, children }: Props): JSX.Element => (
14
+ // Brand display name for SEO, set per whitelabel via VITE_APP_NAME (build-time
15
+ // Vite env). It drives the <title> suffix, the og/twitter titles and
16
+ // og:site_name. When unset, titles render bare (no suffix) and og:site_name is
17
+ // omitted, so a brand that has not configured a name is unaffected. This is
18
+ // what lets every brand consume the published @autobusal/common Meta instead of
19
+ // aliasing a per-brand fork.
20
+ const brand = import.meta.env.VITE_APP_NAME as string | undefined;
21
+
22
+ const withBrand = (value: string): string => (brand ? `${ value } - ${ brand }` : value);
23
+
24
+ const Meta = ({ title, keywords, description, image, url, type = 'website', noIndex = false, children }: Props): JSX.Element => (
11
25
  <>
12
26
  <Helmet>
13
- <title>{ title }</title>
27
+ <title>{ withBrand(title) }</title>
28
+
29
+ {/* Robots */}
30
+ <meta name="robots" content={ noIndex ? 'noindex, nofollow' : 'index, follow' } />
14
31
 
32
+ {/* Standard Meta Tags */}
15
33
  { keywords && <meta name="keywords" content={ keywords } /> }
16
34
  { description && <meta name="description" content={ description } /> }
35
+ { url && <link rel="canonical" href={ url } /> }
36
+
37
+ {/* Open Graph / Facebook */}
38
+ <meta property="og:type" content={ type } />
39
+ <meta property="og:title" content={ withBrand(title) } />
40
+ { description && <meta property="og:description" content={ description } /> }
41
+ { image && <meta property="og:image" content={ image } /> }
42
+ { url && <meta property="og:url" content={ url } /> }
43
+ { brand && <meta property="og:site_name" content={ brand } /> }
44
+
45
+ {/* Twitter */}
46
+ <meta name="twitter:card" content="summary_large_image" />
47
+ <meta name="twitter:title" content={ withBrand(title) } />
48
+ { description && <meta name="twitter:description" content={ description } /> }
49
+ { image && <meta name="twitter:image" content={ image } /> }
17
50
  </Helmet>
18
51
 
19
52
  { children }
20
53
  </>
21
54
  );
22
55
 
23
- export default Meta;
56
+ export default Meta;
@@ -1,6 +1,6 @@
1
1
  import { useState } from 'react';
2
2
  import { TFunction } from 'i18next';
3
- import { UseFormRegister } from 'react-hook-form';
3
+ import { UseFormRegister, UseFormGetValues } from 'react-hook-form';
4
4
  import { AiFillEye, AiFillEyeInvisible } from 'react-icons/ai';
5
5
  import { Validate } from '@autobusal/utilities';
6
6
  import { Container } from './styles';
@@ -9,20 +9,26 @@ interface Props {
9
9
  name: string
10
10
  tabIndex?: number
11
11
  validation?: string
12
+ // Pre-fill value (masked). Lets an admin form show a saved secret as dots
13
+ // instead of blank, so editing another field doesn't wipe it on save.
14
+ defaultValue?: string
12
15
  t: TFunction<'common'>
13
16
  refs: UseFormRegister<any>
17
+ // Pass react-hook-form's getValues to enable cross-field rules like
18
+ // `matches:password` (e.g. a password-confirmation field).
19
+ getValues?: UseFormGetValues<any>
14
20
  }
15
21
 
16
- const Password = ({ name, tabIndex, validation, t, refs }: Props): JSX.Element => {
22
+ const Password = ({ name, tabIndex, validation, defaultValue, t, refs, getValues }: Props): JSX.Element => {
17
23
  const [ show, setShow ] = useState<boolean>(false);
18
24
 
19
25
  const toggle = (): void => {
20
26
  setShow(!show)
21
27
  };
22
-
28
+
23
29
  return (
24
30
  <Container>
25
- <input type={ show ? 'text' : 'password' } tabIndex={ tabIndex } { ...refs(name, Validate(validation ?? '', t)) } />
31
+ <input type={ show ? 'text' : 'password' } tabIndex={ tabIndex } defaultValue={ defaultValue } { ...refs(name, Validate(validation ?? '', t, getValues)) } />
26
32
 
27
33
  { show
28
34
  ? <AiFillEye onClick={ toggle } />
@@ -32,4 +38,4 @@ const Password = ({ name, tabIndex, validation, t, refs }: Props): JSX.Element =
32
38
  );
33
39
  };
34
40
 
35
- export default Password;
41
+ export default Password;
@@ -21,7 +21,7 @@ const RouteItem = ({ type, route, t }: Props): JSX.Element => {
21
21
  <About>
22
22
  <Image>
23
23
  <Logo to={ route.operator.link }>
24
- <img src={ route.operator.logo_url } />
24
+ <img src={ route.operator.logo_url } alt={ route.operator.company } />
25
25
  </Logo>
26
26
  </Image>
27
27
 
@@ -45,8 +45,8 @@ const ChooseSeat = ({ name, type, defaultValue, bus, picked, validation, t, refs
45
45
  <div>
46
46
  <ButtonSeat type="button" $selected={ selected > 0 } onClick={ () => setShow(true) }>
47
47
  { selected > 0
48
- ? <><FaCheck /> { t('seats.chosen', { number: selected }) }</>
49
- : <><MdEventSeat /> { t('seats.choose') }</> }
48
+ ? <><FaCheck /> { t('seats.chosen', { ns: 'common', number: selected }) }</>
49
+ : <><MdEventSeat /> { t('seats.choose', { ns: 'common' }) }</> }
50
50
  </ButtonSeat>
51
51
 
52
52
  { (show && bus.bus !== undefined) && (
@@ -1,4 +1,4 @@
1
- import { FormEvent, useState } from 'react';
1
+ import { FormEvent, useEffect, useRef, useState } from 'react';
2
2
  import { NavigateFunction } from 'react-router-dom';
3
3
  import { TFunction } from 'i18next';
4
4
  import { parseUrl } from '../browseUrl';
@@ -13,12 +13,33 @@ interface Props {
13
13
  const Search = ({ value, url, t, navigate }: Props): JSX.Element => {
14
14
  const [ q, setQ ] = useState<string>(String(value ?? ''));
15
15
 
16
- const handleSubmit = (event: FormEvent<HTMLFormElement>): void => {
17
- event.preventDefault();
16
+ const initial = useRef<boolean>(true);
18
17
 
18
+ const submit = (): void => {
19
19
  const search = parseUrl({ page: 1, q });
20
20
 
21
21
  navigate(`${ url }${ search }`);
22
+ };
23
+
24
+ // typing filters after a short pause - submitting was previously only
25
+ // possible via Enter, which nothing on screen suggested
26
+ useEffect(() => {
27
+ if (initial.current) {
28
+ initial.current = false;
29
+
30
+ return;
31
+ }
32
+
33
+ const timer = setTimeout(submit, 500);
34
+
35
+ return () => clearTimeout(timer);
36
+ // eslint-disable-next-line react-hooks/exhaustive-deps
37
+ }, [q]);
38
+
39
+ const handleSubmit = (event: FormEvent<HTMLFormElement>): void => {
40
+ event.preventDefault();
41
+
42
+ submit();
22
43
  }
23
44
 
24
45
  return (
@@ -33,4 +54,4 @@ const Search = ({ value, url, t, navigate }: Props): JSX.Element => {
33
54
  );
34
55
  };
35
56
 
36
- export default Search;
57
+ export default Search;
package/Viewer/Data.tsx CHANGED
@@ -97,7 +97,7 @@ const Data = ({ id, item, refs, t, onUpdate }: Props): (JSX.Element | null) => {
97
97
  return <input type="number" defaultValue={ value.number } { ...refs(item.name, Validate(String(item.rules), t)) } />
98
98
 
99
99
  case 'password':
100
- return <Password name={ item.name } validation={ item.rules } t={ t } refs={ refs } />;
100
+ return <Password name={ item.name } validation={ item.rules } defaultValue={ value.string } t={ t } refs={ refs } />;
101
101
 
102
102
  case 'select':
103
103
  return <Dropdown item={ item } refs={ refs } t={ t } />;
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@autobusal/common",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
4
+ "author": "Ferjolt Ozuni",
4
5
  "type": "module",
5
6
  "main": "index.ts"
6
- }
7
+ }