@autobusal/common 1.33.14 → 1.34.1
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/Back/BackWithTitle.tsx +61 -2
- package/CHANGELOG.md +12 -0
- package/Calendar/Browse/Browse.tsx +3 -2
- package/Calendar/Calendar.tsx +11 -1
- package/Calendar/Days.tsx +24 -34
- package/Calendar/Picker.tsx +4 -3
- package/Calendar/styles.ts +9 -1
- package/Calendar/utilities/browse.tsx +5 -2
- package/Calendar/utilities/settings.ts +20 -1
- package/Meta.tsx +68 -57
- package/SearchableSelect/SearchableSelect.tsx +199 -0
- package/SearchableSelect/styles.ts +69 -0
- package/SearchableSelect/types.ts +23 -0
- package/Table/Actions/Icon.tsx +16 -1
- package/Viewer/Data.tsx +1 -0
- package/Viewer/types.ts +6 -0
- package/index.ts +2 -0
- package/package.json +1 -1
package/Back/BackWithTitle.tsx
CHANGED
|
@@ -1,11 +1,31 @@
|
|
|
1
1
|
import { TFunction } from 'i18next';
|
|
2
|
+
import { Link } from 'react-router-dom';
|
|
2
3
|
import styled from 'styled-components';
|
|
3
4
|
import BackTo from './BackTo';
|
|
4
5
|
|
|
6
|
+
/**
|
|
7
|
+
* A link offered beside the page title.
|
|
8
|
+
*
|
|
9
|
+
* Edited: Claude - Date: 2026-08-29
|
|
10
|
+
*
|
|
11
|
+
* For the places a record OWNS a list that is the main reason to open it.
|
|
12
|
+
* The country form is the case in point: its "Manage Available Cities"
|
|
13
|
+
* link sat as one row among the form's fields, styled like a value rather
|
|
14
|
+
* than a way out of the page, and the stations underneath it could only be
|
|
15
|
+
* reached by opening a city first. A record's onward journeys belong with
|
|
16
|
+
* its title, not buried between its inputs.
|
|
17
|
+
*/
|
|
18
|
+
export interface TitleAction {
|
|
19
|
+
label: string
|
|
20
|
+
to: string
|
|
21
|
+
icon?: JSX.Element
|
|
22
|
+
}
|
|
23
|
+
|
|
5
24
|
interface Props {
|
|
6
25
|
title: string
|
|
7
26
|
to: string
|
|
8
27
|
t: TFunction<'common'>
|
|
28
|
+
actions?: TitleAction[]
|
|
9
29
|
}
|
|
10
30
|
|
|
11
31
|
const Container = styled.div`
|
|
@@ -19,12 +39,51 @@ const Container = styled.div`
|
|
|
19
39
|
}
|
|
20
40
|
`;
|
|
21
41
|
|
|
22
|
-
const
|
|
42
|
+
const Actions = styled.div`
|
|
43
|
+
display: flex;
|
|
44
|
+
flex-wrap: wrap;
|
|
45
|
+
gap: 8px;
|
|
46
|
+
align-items: center;
|
|
47
|
+
|
|
48
|
+
/* pushed to the far end of the title line, whatever the title's length */
|
|
49
|
+
margin-left: auto;
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
const Action = styled(Link)`
|
|
53
|
+
display: flex;
|
|
54
|
+
align-items: center;
|
|
55
|
+
gap: 6px;
|
|
56
|
+
height: 30px;
|
|
57
|
+
padding: 0 14px;
|
|
58
|
+
font-weight: 700;
|
|
59
|
+
font-size: calc(${ props => props.theme.size.xs } + 1px);
|
|
60
|
+
background: ${ props => props.theme.background.neutral };
|
|
61
|
+
border-radius: 100px;
|
|
62
|
+
transition: all 0.3s ease;
|
|
63
|
+
|
|
64
|
+
&:hover {
|
|
65
|
+
text-decoration: none;
|
|
66
|
+
box-shadow: 0 4px 15px ${ props => props.theme.background.neutral };
|
|
67
|
+
}
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
const BackWithTitle = ({ title, to, t, actions }: Props): JSX.Element => (
|
|
23
71
|
<Container>
|
|
24
72
|
<BackTo to={ to } t={ t } />
|
|
25
73
|
|
|
26
74
|
<h1>{ title }</h1>
|
|
75
|
+
|
|
76
|
+
{ (actions !== undefined && actions.length > 0) && (
|
|
77
|
+
<Actions>
|
|
78
|
+
{ actions.map(action => (
|
|
79
|
+
<Action key={ action.to } to={ action.to }>
|
|
80
|
+
{ action.icon }
|
|
81
|
+
{ action.label }
|
|
82
|
+
</Action>
|
|
83
|
+
)) }
|
|
84
|
+
</Actions>
|
|
85
|
+
) }
|
|
27
86
|
</Container>
|
|
28
87
|
);
|
|
29
88
|
|
|
30
|
-
export default BackWithTitle;
|
|
89
|
+
export default BackWithTitle;
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.34.1 (2026-08-29)
|
|
4
|
+
|
|
5
|
+
- Meta's stale-tag cleanup can no longer remove a tag React owns: it now identifies leftovers by having existed before React rendered, not by comparing values (Sentry BUSMAGUS-7 - on a prerendered page the leftover and the live tag carry the same value, so the old comparison deleted React's own node and the next navigation crashed on removeChild).
|
|
6
|
+
|
|
7
|
+
## 1.34.0 (2026-08-29)
|
|
8
|
+
|
|
9
|
+
- Calendar renders one month per page - the neighbouring months' days are blank padding now, so a grid no longer shows two different 1sts and 31sts.
|
|
10
|
+
- Calendar takes `maxYears`, raising a single picker's ceiling above what its type allows (booking pickers stay at one year); Viewer forwards it on 'picker' rows.
|
|
11
|
+
- New SearchableSelect: a <select> you can type into - accent-insensitive matching, group headings, inert disabled options, written for the operator's stop picker.
|
|
12
|
+
- BackWithTitle takes `actions`, rendered as pill links at the far end of the title line.
|
|
13
|
+
- Table row actions for 'cities' and 'stations'.
|
|
14
|
+
|
|
3
15
|
## 1.33.14 (2026-08-26)
|
|
4
16
|
|
|
5
17
|
- ChooseSeat: seat-selection add-on mode - `optional` + `fee` props; unpicked state shows the free-auto note, a made choice gets a back-to-automatic control. Default behaviour (required free pick) unchanged.
|
|
@@ -8,11 +8,12 @@ import { Container, ButtonBrowse, Choose, Select } from './styles';
|
|
|
8
8
|
interface Props {
|
|
9
9
|
type: 'picker' | 'dob' | 'date'
|
|
10
10
|
value: string
|
|
11
|
+
maxYears?: number
|
|
11
12
|
t: TFunction<'general'>
|
|
12
13
|
onChange: (date: Date) => void
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
const Browse = ({ type, value, t, onChange }: Props): JSX.Element => {
|
|
16
|
+
const Browse = ({ type, value, maxYears, t, onChange }: Props): JSX.Element => {
|
|
16
17
|
const date = createDate(value);
|
|
17
18
|
|
|
18
19
|
const onPrevious = (): void => {
|
|
@@ -51,7 +52,7 @@ const Browse = ({ type, value, t, onChange }: Props): JSX.Element => {
|
|
|
51
52
|
</Select>
|
|
52
53
|
|
|
53
54
|
<Select $type="year" value={ date.getFullYear() } onChange={ onYear }>
|
|
54
|
-
{ getYears(type, date.getFullYear()) }
|
|
55
|
+
{ getYears(type, date.getFullYear(), maxYears) }
|
|
55
56
|
</Select>
|
|
56
57
|
</Choose>
|
|
57
58
|
|
package/Calendar/Calendar.tsx
CHANGED
|
@@ -32,6 +32,15 @@ interface Props {
|
|
|
32
32
|
* alert after pressing search.
|
|
33
33
|
*/
|
|
34
34
|
minDate?: string
|
|
35
|
+
/**
|
|
36
|
+
* Edited: Claude - Date: 2026-08-29
|
|
37
|
+
* How many years ahead this picker may reach, when the type's own ceiling
|
|
38
|
+
* is too near. A booking picker stops at one year because that is how far
|
|
39
|
+
* ahead seats are sold; an operator planning a route SCHEDULE works in
|
|
40
|
+
* multi-year seasons, and had no way to enter one. Raises the ceiling
|
|
41
|
+
* only - it can never bring a picker below what its type already allows.
|
|
42
|
+
*/
|
|
43
|
+
maxYears?: number
|
|
35
44
|
refs: UseFormRegister<any>
|
|
36
45
|
onUpdate: UseFormSetValue<any>
|
|
37
46
|
/**
|
|
@@ -42,7 +51,7 @@ interface Props {
|
|
|
42
51
|
onChange?: (value: string) => void
|
|
43
52
|
}
|
|
44
53
|
|
|
45
|
-
const Calendar = ({ type, name, label, id, defaultValue, t, validation, minDate, refs, onUpdate, onChange }: Props): JSX.Element => {
|
|
54
|
+
const Calendar = ({ type, name, label, id, defaultValue, t, validation, minDate, maxYears, refs, onUpdate, onChange }: Props): JSX.Element => {
|
|
46
55
|
const [ show, setShow ] = useState<boolean>(false);
|
|
47
56
|
|
|
48
57
|
/**
|
|
@@ -93,6 +102,7 @@ const Calendar = ({ type, name, label, id, defaultValue, t, validation, minDate,
|
|
|
93
102
|
value={ prepared || prepareDate(new Date()) }
|
|
94
103
|
selected={ selected }
|
|
95
104
|
minDate={ minDate }
|
|
105
|
+
maxYears={ maxYears }
|
|
96
106
|
t={ t }
|
|
97
107
|
onClick={ onClick }
|
|
98
108
|
/>
|
package/Calendar/Days.tsx
CHANGED
|
@@ -9,10 +9,16 @@ interface Props {
|
|
|
9
9
|
value: string
|
|
10
10
|
selected: string
|
|
11
11
|
minDate?: string
|
|
12
|
+
/**
|
|
13
|
+
* Edited: Claude - Date: 2026-08-29
|
|
14
|
+
* How many years ahead this particular picker may reach, overriding what
|
|
15
|
+
* the type allows. See the note on Calendar's own prop.
|
|
16
|
+
*/
|
|
17
|
+
maxYears?: number
|
|
12
18
|
onSelected: (prepared: string, disabled: boolean) => void
|
|
13
19
|
}
|
|
14
20
|
|
|
15
|
-
const Days = ({ type, value, selected, minDate, onSelected }: Props): JSX.Element => {
|
|
21
|
+
const Days = ({ type, value, selected, minDate, maxYears, onSelected }: Props): JSX.Element => {
|
|
16
22
|
const [ calendar, setCalendar ] = useState<CalendarData>(getCalendar(value));
|
|
17
23
|
|
|
18
24
|
useEffect(() => {
|
|
@@ -24,7 +30,7 @@ const Days = ({ type, value, selected, minDate, onSelected }: Props): JSX.Elemen
|
|
|
24
30
|
// getLimits below, which for a booking picker follows the server.
|
|
25
31
|
const today = prepareDate(new Date());
|
|
26
32
|
|
|
27
|
-
const limits = getLimits(type);
|
|
33
|
+
const limits = getLimits(type, maxYears);
|
|
28
34
|
|
|
29
35
|
/**
|
|
30
36
|
* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
@@ -47,45 +53,29 @@ const Days = ({ type, value, selected, minDate, onSelected }: Props): JSX.Elemen
|
|
|
47
53
|
|
|
48
54
|
let currentDay: number = 1;
|
|
49
55
|
|
|
50
|
-
// prepare the previous date
|
|
51
|
-
const previousDate: Date = createDate(value);
|
|
52
|
-
previousDate.setMonth(previousDate.getMonth() - 1);
|
|
53
|
-
|
|
54
56
|
// setup the current date
|
|
55
57
|
const currentDate: Date = createDate(value);
|
|
56
58
|
|
|
57
|
-
// prepare the next date
|
|
58
|
-
const nextDate: Date = createDate(value);
|
|
59
|
-
nextDate.setMonth(nextDate.getMonth() + 1);
|
|
60
|
-
|
|
61
59
|
for (let i = 1; i <= calendar.weeks; i++) {
|
|
62
60
|
for (let j = 1; j <= 7; j++) {
|
|
63
61
|
const key = i * 10 + j;
|
|
64
62
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
rows.push(
|
|
83
|
-
<Day key={ key } $type="other" $disabled={ disabled }>
|
|
84
|
-
<span>{ calendar.next }</span>
|
|
85
|
-
</Day>
|
|
86
|
-
);
|
|
87
|
-
|
|
88
|
-
calendar.next++;
|
|
63
|
+
/*
|
|
64
|
+
* Edited: Claude - Date: 2026-08-29
|
|
65
|
+
*
|
|
66
|
+
* ONE MONTH PER PAGE. The leading and trailing cells used to spell out
|
|
67
|
+
* the neighbouring months' days - so a picker opened on September
|
|
68
|
+
* showed "31" (August) in the first cell and "1, 2, 3" (October) in the
|
|
69
|
+
* last, greyed but numbered. Two different 1sts and two different 31sts
|
|
70
|
+
* on screen is exactly the ambiguity a date picker exists to remove,
|
|
71
|
+
* and on the booking form those neighbours were legitimately selectable
|
|
72
|
+
* dates rendered as though they were not part of the month being read.
|
|
73
|
+
*
|
|
74
|
+
* The cells stay - blank - because the grid is what puts the 1st under
|
|
75
|
+
* the right weekday; only the foreign numbers go.
|
|
76
|
+
*/
|
|
77
|
+
if ((i == 1 && j < calendar.begins) || currentDay > calendar.month) {
|
|
78
|
+
rows.push(<Day key={ key } $type="blank" aria-hidden={ true } />);
|
|
89
79
|
} else {
|
|
90
80
|
currentDate.setDate(currentDay);
|
|
91
81
|
|
package/Calendar/Picker.tsx
CHANGED
|
@@ -11,11 +11,12 @@ interface Props {
|
|
|
11
11
|
value: string
|
|
12
12
|
selected: string
|
|
13
13
|
minDate?: string
|
|
14
|
+
maxYears?: number
|
|
14
15
|
t: TFunction<'general'>
|
|
15
16
|
onClick: (prepared: string) => void
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
const Picker = ({ type, value, selected, minDate, t, onClick }: Props): (JSX.Element | null) => {
|
|
19
|
+
const Picker = ({ type, value, selected, minDate, maxYears, t, onClick }: Props): (JSX.Element | null) => {
|
|
19
20
|
const [ prepared, setPrepared ] = useState<string>(value);
|
|
20
21
|
|
|
21
22
|
const onSelected = (prepared: string, disabled: boolean): void => {
|
|
@@ -39,11 +40,11 @@ const Picker = ({ type, value, selected, minDate, t, onClick }: Props): (JSX.Ele
|
|
|
39
40
|
|
|
40
41
|
return (
|
|
41
42
|
<ContainerPicker>
|
|
42
|
-
<Browse type={ type } value={ prepared } t={ t } onChange={ onChange } />
|
|
43
|
+
<Browse type={ type } value={ prepared } maxYears={ maxYears } t={ t } onChange={ onChange } />
|
|
43
44
|
|
|
44
45
|
<Header t={ t } />
|
|
45
46
|
|
|
46
|
-
<Days type={ type } value={ prepared } selected={ selected } minDate={ minDate } onSelected={ onSelected } />
|
|
47
|
+
<Days type={ type } value={ prepared } selected={ selected } minDate={ minDate } maxYears={ maxYears } onSelected={ onSelected } />
|
|
47
48
|
</ContainerPicker>
|
|
48
49
|
);
|
|
49
50
|
};
|
package/Calendar/styles.ts
CHANGED
|
@@ -22,7 +22,7 @@ export const ContainerDays = styled.div`
|
|
|
22
22
|
`;
|
|
23
23
|
|
|
24
24
|
export const Day = styled.div<{
|
|
25
|
-
$type?: ('today' | 'other')
|
|
25
|
+
$type?: ('today' | 'other' | 'blank')
|
|
26
26
|
$disabled?: boolean
|
|
27
27
|
$selected?: boolean
|
|
28
28
|
}>`
|
|
@@ -31,6 +31,14 @@ export const Day = styled.div<{
|
|
|
31
31
|
padding: 2px;
|
|
32
32
|
cursor: pointer;
|
|
33
33
|
|
|
34
|
+
/* Edited: Claude - Date: 2026-08-29: the padding cells that hold the 1st
|
|
35
|
+
under its weekday - they hold space and nothing else (see Days.tsx) */
|
|
36
|
+
${ props => props.$type === 'blank' && css`
|
|
37
|
+
height: 30px;
|
|
38
|
+
cursor: default;
|
|
39
|
+
pointer-events: none;
|
|
40
|
+
` }
|
|
41
|
+
|
|
34
42
|
& > span {
|
|
35
43
|
width: 26px;
|
|
36
44
|
height: 26px;
|
|
@@ -26,8 +26,11 @@ export const getMonths = (t: TFunction<'general'>): JSX.Element[] => {
|
|
|
26
26
|
return options;
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
-
export const getYears = (type: ('picker' | 'dob' | 'date'), currentYear: number): JSX.Element[] => {
|
|
30
|
-
|
|
29
|
+
export const getYears = (type: ('picker' | 'dob' | 'date'), currentYear: number, maxYears?: number): JSX.Element[] => {
|
|
30
|
+
// Edited: Claude - Date: 2026-08-29: the year dropdown has to reach as far
|
|
31
|
+
// as the day grid does, or a five-year schedule could be validated but
|
|
32
|
+
// never navigated to
|
|
33
|
+
const limits = getLimits(type, maxYears);
|
|
31
34
|
|
|
32
35
|
const current = new Date().getFullYear();
|
|
33
36
|
const start = current - limits.year.min;
|
|
@@ -47,7 +47,17 @@ export const setServerToday = (value?: string): void => {
|
|
|
47
47
|
serverToday = new Date(year, month - 1, day, 0, 0, 0, 0);
|
|
48
48
|
};
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Edited: Claude - Date: 2026-08-29
|
|
52
|
+
*
|
|
53
|
+
* `maxYears` raises this picker's ceiling above what its type allows, for
|
|
54
|
+
* the callers that genuinely reach further than a booking does: an
|
|
55
|
+
* operator's route schedule is now planned up to five years out, while the
|
|
56
|
+
* booking picker every passenger uses stays at one. Only the ceiling moves
|
|
57
|
+
* - the floor, and the whole of the `dob` and `date` behaviour, are
|
|
58
|
+
* untouched, and omitting it leaves every existing picker exactly as it was.
|
|
59
|
+
*/
|
|
60
|
+
export const getLimits = (type: ('picker' | 'dob' | 'date'), maxYears?: number): CalendarLimit => {
|
|
51
61
|
// Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
52
62
|
//
|
|
53
63
|
// The BOOKING floor comes from the server; a date of birth does not.
|
|
@@ -113,6 +123,15 @@ export const getLimits = (type: ('picker' | 'dob' | 'date')): CalendarLimit => {
|
|
|
113
123
|
limits.max.year = 0;
|
|
114
124
|
}
|
|
115
125
|
|
|
126
|
+
if (maxYears !== undefined && maxYears > limits.max.year) {
|
|
127
|
+
limits.max.year = maxYears;
|
|
128
|
+
|
|
129
|
+
// the ceiling is a whole year further out, so it ends on ITS last day -
|
|
130
|
+
// a max of {year + 5, month 11, day 31} rather than today's month/day
|
|
131
|
+
limits.max.month = 11;
|
|
132
|
+
limits.max.day = 31;
|
|
133
|
+
}
|
|
134
|
+
|
|
116
135
|
return {
|
|
117
136
|
year: {
|
|
118
137
|
min: limits.min.year,
|
package/Meta.tsx
CHANGED
|
@@ -46,19 +46,42 @@ const withBrand = (value: string): string => (brand ? `${ value } - ${ brand }`
|
|
|
46
46
|
// title alone; a description/canonical duplicate is worse, since crawlers
|
|
47
47
|
// commonly pick the FIRST occurrence, which is the potentially-stale one.
|
|
48
48
|
//
|
|
49
|
-
// This makes Meta the sole owner of every tag it manages:
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
49
|
+
// This makes Meta the sole owner of every tag it manages: whatever was
|
|
50
|
+
// carrying one of these before React rendered goes, and React's own output
|
|
51
|
+
// is all that is left. Edited: Claude - 2026-08-29 - it used to decide that
|
|
52
|
+
// by VALUE (keep the element whose value matches this page, drop the rest),
|
|
53
|
+
// which is what BUSMAGUS-7 turned out to be; see preReactHeadTags below.
|
|
54
|
+
// A selector is all this needs now: the hreflang alternates, which
|
|
55
|
+
// legitimately repeat, need no special case either, because the rule is no
|
|
56
|
+
// longer "keep one of them".
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Every head tag that existed BEFORE React rendered anything.
|
|
60
|
+
*
|
|
61
|
+
* Edited: Claude - Date: 2026-08-29 (Sentry BUSMAGUS-7)
|
|
62
|
+
*
|
|
63
|
+
* The cleanup below could not tell a leftover prerendered tag from React's
|
|
64
|
+
* own freshly hoisted one, so it compared VALUES and kept the first match
|
|
65
|
+
* in document order. On a prerendered page those values are identical by
|
|
66
|
+
* construction - the snapshot was produced by this very component - and the
|
|
67
|
+
* first match is always the snapshot's, sitting in the served HTML. So the
|
|
68
|
+
* cleanup kept the dead tag and removed REACT'S: the fiber went on holding
|
|
69
|
+
* a detached node, and the next navigation's unmount reached
|
|
70
|
+
* `n.parentNode.removeChild(n)` with a null parent. That is BUSMAGUS-7,
|
|
71
|
+
* thrown from React 19's HostHoistable deletion path, on a
|
|
72
|
+
* /bus-lines/... URL - and it aborts the navigation in flight, which is
|
|
73
|
+
* what the DOM-corruption watchdog in @autobusal/providers then has to
|
|
74
|
+
* paper over with a full reload.
|
|
75
|
+
*
|
|
76
|
+
* This module is evaluated before the first <Meta> can render, and
|
|
77
|
+
* `createRoot` (unlike `hydrateRoot`) never adopts existing markup - so
|
|
78
|
+
* anything captured here is provably NOT React's, and anything React
|
|
79
|
+
* hoists later is provably not in here. Identity instead of value: the
|
|
80
|
+
* cleanup can now only ever remove tags React does not own.
|
|
81
|
+
*/
|
|
82
|
+
const preReactHeadTags: Set<Element> = new Set(
|
|
83
|
+
typeof document === 'undefined' ? [] : document.head.querySelectorAll('title, meta, link')
|
|
84
|
+
);
|
|
62
85
|
|
|
63
86
|
// Edited: Ferjolt Ozuni - Date: 2026-07-31
|
|
64
87
|
// Bug: this ran on EVERY render of EVERY <Meta> usage (no dependency array),
|
|
@@ -86,7 +109,7 @@ type OwnedTag = { selector: string, attr: 'text' | string, value?: string, value
|
|
|
86
109
|
// is the sole owner of this part of the DOM.
|
|
87
110
|
let hasCleanedPrerenderedTags = false;
|
|
88
111
|
|
|
89
|
-
const useSoleMetaOwnership = (
|
|
112
|
+
const useSoleMetaOwnership = (selectors: string[]): void => {
|
|
90
113
|
useEffect(() => {
|
|
91
114
|
if (hasCleanedPrerenderedTags) {
|
|
92
115
|
return;
|
|
@@ -94,33 +117,21 @@ const useSoleMetaOwnership = (tags: OwnedTag[]): void => {
|
|
|
94
117
|
|
|
95
118
|
hasCleanedPrerenderedTags = true;
|
|
96
119
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (value === undefined) {
|
|
113
|
-
elements.forEach(el => el.remove());
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const mine = elements.find(el => (
|
|
118
|
-
attr === 'text' ? el.textContent === value : el.getAttribute(attr) === value
|
|
119
|
-
));
|
|
120
|
-
|
|
121
|
-
elements.forEach(el => {
|
|
122
|
-
if (el !== mine) {
|
|
123
|
-
el.remove();
|
|
120
|
+
/*
|
|
121
|
+
* Edited: Claude - Date: 2026-08-29 (Sentry BUSMAGUS-7)
|
|
122
|
+
*
|
|
123
|
+
* Remove the tags that were here before React was, and nothing else.
|
|
124
|
+
* React has already committed its own copy of every tag this component
|
|
125
|
+
* owns by the time this effect runs, so a leftover is by definition one
|
|
126
|
+
* of the nodes captured above - no value comparison needed, and none
|
|
127
|
+
* possible: on a prerendered page the leftover and the live tag carry
|
|
128
|
+
* the SAME value, which is precisely how the old comparison came to
|
|
129
|
+
* delete React's own node. See the note on preReactHeadTags.
|
|
130
|
+
*/
|
|
131
|
+
selectors.forEach(selector => {
|
|
132
|
+
document.querySelectorAll(`head > ${ selector }`).forEach(element => {
|
|
133
|
+
if (preReactHeadTags.has(element)) {
|
|
134
|
+
element.remove();
|
|
124
135
|
}
|
|
125
136
|
});
|
|
126
137
|
});
|
|
@@ -273,22 +284,22 @@ const Meta = ({ title, keywords, description, image, url, type = 'website', noIn
|
|
|
273
284
|
const alternates = useAlternates(noIndex, url);
|
|
274
285
|
|
|
275
286
|
useSoleMetaOwnership([
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
287
|
+
'title',
|
|
288
|
+
'meta[name="robots"]',
|
|
289
|
+
'meta[name="keywords"]',
|
|
290
|
+
'meta[name="description"]',
|
|
291
|
+
'link[rel="canonical"]',
|
|
292
|
+
'link[rel="alternate"][hreflang]',
|
|
293
|
+
'meta[property="og:type"]',
|
|
294
|
+
'meta[property="og:title"]',
|
|
295
|
+
'meta[property="og:description"]',
|
|
296
|
+
'meta[property="og:image"]',
|
|
297
|
+
'meta[property="og:url"]',
|
|
298
|
+
'meta[property="og:site_name"]',
|
|
299
|
+
'meta[name="twitter:card"]',
|
|
300
|
+
'meta[name="twitter:title"]',
|
|
301
|
+
'meta[name="twitter:description"]',
|
|
302
|
+
'meta[name="twitter:image"]'
|
|
292
303
|
]);
|
|
293
304
|
usePageviewTracking(location.pathname + location.search, fullTitle);
|
|
294
305
|
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { useState, useRef, useEffect, ChangeEvent, KeyboardEvent } from 'react';
|
|
2
|
+
import { TFunction } from 'i18next';
|
|
3
|
+
import { UseFormRegister, UseFormSetValue } from 'react-hook-form';
|
|
4
|
+
import { useOutside } from '@autobusal/hooks';
|
|
5
|
+
import { Validate } from '@autobusal/utilities';
|
|
6
|
+
import { fold } from '../Autocomplete/fold';
|
|
7
|
+
import { Container, ContainerOptions, Group, Option, Note, Empty } from './styles';
|
|
8
|
+
import { SearchableOption } from './types';
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
name: string
|
|
12
|
+
options: SearchableOption[]
|
|
13
|
+
/** shown while nothing is chosen, and as the input's accessible name */
|
|
14
|
+
placeholder: string
|
|
15
|
+
/** what to say when the query matches nothing */
|
|
16
|
+
empty: string
|
|
17
|
+
defaultValue?: string
|
|
18
|
+
validation?: string
|
|
19
|
+
t: TFunction<any>
|
|
20
|
+
refs: UseFormRegister<any>
|
|
21
|
+
onUpdate: UseFormSetValue<any>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A <select> you can type into.
|
|
26
|
+
*
|
|
27
|
+
* Edited: Claude - Date: 2026-08-29
|
|
28
|
+
*
|
|
29
|
+
* Written for the operator's "add a stop to this route" control, which was
|
|
30
|
+
* a native select listing every station on the platform grouped by country
|
|
31
|
+
* - hundreds of options, in an order no operator knows, with no way to
|
|
32
|
+
* search. Finding one's own city meant scrolling the whole list (and on a
|
|
33
|
+
* phone, scrolling a native picker of the same length).
|
|
34
|
+
*
|
|
35
|
+
* Matching is accent-insensitive through the same `fold` the public search
|
|
36
|
+
* box uses, so "korce" finds "Korçë" - the spelling anybody without an
|
|
37
|
+
* Albanian keyboard actually types. Empty query lists everything, so it
|
|
38
|
+
* still behaves like the select it replaces if nobody types at all.
|
|
39
|
+
*
|
|
40
|
+
* The chosen value lives in a hidden input registered with react-hook-form,
|
|
41
|
+
* the same idiom Calendar and Autocomplete already use here, so the
|
|
42
|
+
* surrounding form and its validation are unchanged.
|
|
43
|
+
*/
|
|
44
|
+
const SearchableSelect = ({ name, options, placeholder, empty, defaultValue, validation, t, refs, onUpdate }: Props): JSX.Element => {
|
|
45
|
+
const [ show, setShow ] = useState<boolean>(false);
|
|
46
|
+
const [ value, setValue ] = useState<string>(defaultValue ?? '');
|
|
47
|
+
const [ q, setQ ] = useState<string>('');
|
|
48
|
+
const [ highlighted, setHighlighted ] = useState<number>(-1);
|
|
49
|
+
|
|
50
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
51
|
+
|
|
52
|
+
useOutside(ref, () => setShow(false));
|
|
53
|
+
|
|
54
|
+
const chosen = options.find(option => option.value === value);
|
|
55
|
+
|
|
56
|
+
// the option list arrives asynchronously and is rebuilt when the language
|
|
57
|
+
// changes; a value chosen before that must keep showing the right label
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (value && !options.some(option => option.value === value)) {
|
|
60
|
+
setValue('');
|
|
61
|
+
onUpdate(name, '');
|
|
62
|
+
}
|
|
63
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
64
|
+
}, [ options ]);
|
|
65
|
+
|
|
66
|
+
const needle = fold(q.trim());
|
|
67
|
+
|
|
68
|
+
const matches = needle === ''
|
|
69
|
+
? options
|
|
70
|
+
: options.filter(option => (
|
|
71
|
+
[ option.label, option.group ?? '', ...(option.search ?? []) ]
|
|
72
|
+
.some(candidate => fold(candidate).includes(needle))
|
|
73
|
+
));
|
|
74
|
+
|
|
75
|
+
// only the choosable ones take part in keyboard navigation
|
|
76
|
+
const navigable = matches.filter(option => option.disabled !== true);
|
|
77
|
+
|
|
78
|
+
const onSelect = (option: SearchableOption): void => {
|
|
79
|
+
if (option.disabled === true) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
setValue(option.value);
|
|
84
|
+
|
|
85
|
+
// shouldValidate so a "required" message from an earlier submit clears
|
|
86
|
+
// the instant a station is chosen, rather than lingering over a filled
|
|
87
|
+
// field until the next submit
|
|
88
|
+
onUpdate(name, option.value, { shouldValidate: true });
|
|
89
|
+
|
|
90
|
+
setQ('');
|
|
91
|
+
setHighlighted(-1);
|
|
92
|
+
setShow(false);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const onChange = (event: ChangeEvent<HTMLInputElement>): void => {
|
|
96
|
+
setQ(event.target.value);
|
|
97
|
+
|
|
98
|
+
setShow(true);
|
|
99
|
+
setHighlighted(-1);
|
|
100
|
+
|
|
101
|
+
// typing means the previous choice is being replaced - clear it now so a
|
|
102
|
+
// half-typed query can never be submitted as the old selection
|
|
103
|
+
if (value) {
|
|
104
|
+
setValue('');
|
|
105
|
+
onUpdate(name, '');
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
|
|
110
|
+
if (event.key === 'Escape') {
|
|
111
|
+
setShow(false);
|
|
112
|
+
} else if (event.key === 'ArrowDown') {
|
|
113
|
+
event.preventDefault();
|
|
114
|
+
|
|
115
|
+
setShow(true);
|
|
116
|
+
setHighlighted(Math.min(highlighted + 1, navigable.length - 1));
|
|
117
|
+
} else if (event.key === 'ArrowUp') {
|
|
118
|
+
event.preventDefault();
|
|
119
|
+
|
|
120
|
+
setHighlighted(Math.max(highlighted - 1, 0));
|
|
121
|
+
} else if (event.key === 'Enter') {
|
|
122
|
+
// a picker inside a form: Enter chooses the highlighted option rather
|
|
123
|
+
// than submitting whatever is half-typed
|
|
124
|
+
event.preventDefault();
|
|
125
|
+
|
|
126
|
+
const option = navigable[highlighted];
|
|
127
|
+
|
|
128
|
+
if (option !== undefined) {
|
|
129
|
+
onSelect(option);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const items: JSX.Element[] = [];
|
|
135
|
+
|
|
136
|
+
let group: string | undefined;
|
|
137
|
+
|
|
138
|
+
matches.forEach(option => {
|
|
139
|
+
if (option.group !== undefined && option.group !== group) {
|
|
140
|
+
group = option.group;
|
|
141
|
+
|
|
142
|
+
items.push(<Group key={ `group-${ group }` }>{ group }</Group>);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const index = navigable.indexOf(option);
|
|
146
|
+
|
|
147
|
+
items.push(
|
|
148
|
+
<Option
|
|
149
|
+
key={ option.value }
|
|
150
|
+
type="button"
|
|
151
|
+
$selected={ index >= 0 && index === highlighted }
|
|
152
|
+
$disabled={ option.disabled }
|
|
153
|
+
aria-disabled={ option.disabled }
|
|
154
|
+
onClick={ () => onSelect(option) }
|
|
155
|
+
>
|
|
156
|
+
{ option.label }
|
|
157
|
+
|
|
158
|
+
{ option.note !== undefined && <Note>({ option.note })</Note> }
|
|
159
|
+
</Option>
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<Container ref={ ref }>
|
|
165
|
+
<input
|
|
166
|
+
type="text"
|
|
167
|
+
autoComplete="off"
|
|
168
|
+
aria-label={ placeholder }
|
|
169
|
+
placeholder={ placeholder }
|
|
170
|
+
value={ show ? q : (chosen?.label ?? '') }
|
|
171
|
+
onFocus={ () => setShow(true) }
|
|
172
|
+
onClick={ () => setShow(true) }
|
|
173
|
+
onChange={ onChange }
|
|
174
|
+
onKeyDown={ onKeyDown }
|
|
175
|
+
/>
|
|
176
|
+
|
|
177
|
+
{ show && (
|
|
178
|
+
<ContainerOptions>
|
|
179
|
+
{ items.length > 0 ? items : <Empty>{ empty }</Empty> }
|
|
180
|
+
</ContainerOptions>
|
|
181
|
+
) }
|
|
182
|
+
|
|
183
|
+
{ /* The same idiom Calendar uses, INCLUDING the part that looks
|
|
184
|
+
redundant: `defaultValue` must track the chosen value, not stay
|
|
185
|
+
fixed at the initial one.
|
|
186
|
+
|
|
187
|
+
React re-applies a hidden input's `defaultValue` on every update,
|
|
188
|
+
and because react-hook-form writes the field's value as a plain
|
|
189
|
+
property assignment (which never marks the node dirty), a static
|
|
190
|
+
defaultValue silently wiped that write on the next render. The
|
|
191
|
+
label showed the choice, the form saw an empty field, and the
|
|
192
|
+
submit answered "this field is required" with an option visibly
|
|
193
|
+
selected. Caught in the browser; the types could not have. */ }
|
|
194
|
+
<input type="hidden" defaultValue={ value } { ...refs(name, Validate(validation ?? '', t)) } />
|
|
195
|
+
</Container>
|
|
196
|
+
);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
export default SearchableSelect;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import styled, { css } from 'styled-components';
|
|
2
|
+
|
|
3
|
+
export const Container = styled.div`
|
|
4
|
+
position: relative;
|
|
5
|
+
width: 100%;
|
|
6
|
+
`;
|
|
7
|
+
|
|
8
|
+
export const ContainerOptions = styled.div`
|
|
9
|
+
position: absolute;
|
|
10
|
+
top: 44px;
|
|
11
|
+
left: 0;
|
|
12
|
+
z-index: 100;
|
|
13
|
+
width: 100%;
|
|
14
|
+
min-width: 260px;
|
|
15
|
+
max-height: 260px;
|
|
16
|
+
overflow-y: auto;
|
|
17
|
+
display: flex;
|
|
18
|
+
flex-direction: column;
|
|
19
|
+
gap: 2px;
|
|
20
|
+
padding: 6px;
|
|
21
|
+
background: ${ props => props.theme.foreground.normal };
|
|
22
|
+
box-shadow: ${ props => props.theme.boxShadow };
|
|
23
|
+
border-radius: ${ props => props.theme.borderRadius };
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
export const Group = styled.div`
|
|
27
|
+
padding: 8px 12px 4px;
|
|
28
|
+
font-weight: 700;
|
|
29
|
+
font-size: ${ props => props.theme.size.xs };
|
|
30
|
+
color: ${ props => props.theme.font.faded };
|
|
31
|
+
text-transform: uppercase;
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
export const Option = styled.button<{ $selected: boolean, $disabled?: boolean }>`
|
|
35
|
+
padding: 6px 12px;
|
|
36
|
+
text-align: left;
|
|
37
|
+
border-radius: ${ props => props.theme.borderRadius };
|
|
38
|
+
transition: all 0.3s ease;
|
|
39
|
+
|
|
40
|
+
&:hover {
|
|
41
|
+
color: ${ props => props.theme.primary.contrast };
|
|
42
|
+
background-color: ${ props => props.theme.primary.normal };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
${ props => props.$selected && css`
|
|
46
|
+
color: ${ props => props.theme.primary.contrast };
|
|
47
|
+
background-color: ${ props => props.theme.primary.normal };
|
|
48
|
+
` }
|
|
49
|
+
|
|
50
|
+
/* a station awaiting approval is shown so the proposal does not look
|
|
51
|
+
lost, and is inert because the server refuses it anyway */
|
|
52
|
+
${ props => props.$disabled && css`
|
|
53
|
+
opacity: .45;
|
|
54
|
+
cursor: default;
|
|
55
|
+
pointer-events: none;
|
|
56
|
+
` }
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
export const Note = styled.span`
|
|
60
|
+
margin-left: 6px;
|
|
61
|
+
font-size: ${ props => props.theme.size.xs };
|
|
62
|
+
opacity: .8;
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
export const Empty = styled.div`
|
|
66
|
+
padding: 10px 12px;
|
|
67
|
+
font-size: ${ props => props.theme.size.xs };
|
|
68
|
+
color: ${ props => props.theme.font.faded };
|
|
69
|
+
`;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface SearchableOption {
|
|
2
|
+
/** what the form submits */
|
|
3
|
+
value: string
|
|
4
|
+
|
|
5
|
+
/** what the option shows, already in the reader's language */
|
|
6
|
+
label: string
|
|
7
|
+
|
|
8
|
+
/** heading this option sits under, e.g. its country */
|
|
9
|
+
group?: string
|
|
10
|
+
|
|
11
|
+
/** listed, but not choosable - a station awaiting approval, say */
|
|
12
|
+
disabled?: boolean
|
|
13
|
+
|
|
14
|
+
/** short parenthetical after the label, e.g. "pending approval" */
|
|
15
|
+
note?: string
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Extra spellings this option should MATCH on but never display - the
|
|
19
|
+
* same idea as Autocomplete's own `search`, so a Greek station can be
|
|
20
|
+
* found by typing its Latin name and the other way round.
|
|
21
|
+
*/
|
|
22
|
+
search?: string[]
|
|
23
|
+
}
|
package/Table/Actions/Icon.tsx
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { TFunction } from 'i18next';
|
|
2
2
|
import { AiOutlineCheck, AiOutlineCloudDownload } from 'react-icons/ai';
|
|
3
3
|
import { BiEditAlt, BiTransfer, BiTrash } from 'react-icons/bi';
|
|
4
|
-
import { FaTrash } from 'react-icons/fa';
|
|
4
|
+
import { FaCity, FaTrash } from 'react-icons/fa';
|
|
5
5
|
import { GiMoneyStack } from 'react-icons/gi';
|
|
6
6
|
import { IoDocumentLockOutline } from 'react-icons/io5';
|
|
7
|
+
import { MdLocationOn } from 'react-icons/md';
|
|
7
8
|
import { FaFileInvoice } from 'react-icons/fa';
|
|
8
9
|
import { RiBusLine, RiExternalLinkLine, RiEyeLine, RiLoginCircleLine, RiPrinterLine, RiReplyLine } from 'react-icons/ri';
|
|
9
10
|
|
|
@@ -71,6 +72,20 @@ const Icon = ({ type, t }: Props): (JSX.Element | null) => {
|
|
|
71
72
|
case 'bus':
|
|
72
73
|
return <RiBusLine title={ t('table.actions.custom.bus', { ns: 'common' }) } />;
|
|
73
74
|
|
|
75
|
+
/*
|
|
76
|
+
* Edited: Claude - Date: 2026-08-29
|
|
77
|
+
*
|
|
78
|
+
* Locations are three levels deep - country, then its cities, then a
|
|
79
|
+
* city's stations - and reaching a station meant opening the country,
|
|
80
|
+
* opening the city, and only then the stops. These two put both lists
|
|
81
|
+
* one click from the country row itself.
|
|
82
|
+
*/
|
|
83
|
+
case 'cities':
|
|
84
|
+
return <FaCity title={ t('table.actions.custom.cities', { ns: 'common' }) } />;
|
|
85
|
+
|
|
86
|
+
case 'stops':
|
|
87
|
+
return <MdLocationOn title={ t('table.actions.custom.stops', { ns: 'common' }) } />;
|
|
88
|
+
|
|
74
89
|
case 'douane':
|
|
75
90
|
return <IoDocumentLockOutline title={ t('table.actions.custom.douane', { ns: 'common' }) } />;
|
|
76
91
|
}
|
package/Viewer/Data.tsx
CHANGED
|
@@ -110,6 +110,7 @@ const Data = ({ id, field, item, refs, t, onUpdate }: Props): (JSX.Element | nul
|
|
|
110
110
|
defaultValue={ value.string }
|
|
111
111
|
t={ t }
|
|
112
112
|
validation={ item.rules }
|
|
113
|
+
maxYears={ item.maxYears }
|
|
113
114
|
refs={ refs }
|
|
114
115
|
onUpdate={ onUpdate }
|
|
115
116
|
// Edited: Ferjolt Ozuni - Date: 2026-08-01
|
package/Viewer/types.ts
CHANGED
|
@@ -25,6 +25,12 @@ export interface ViewData {
|
|
|
25
25
|
values?: DropdownData[]
|
|
26
26
|
selected?: any[]
|
|
27
27
|
rules?: string
|
|
28
|
+
/**
|
|
29
|
+
* Edited: Claude - Date: 2026-08-29
|
|
30
|
+
* For 'picker' rows only: how many years ahead the calendar may reach.
|
|
31
|
+
* Omitted, the picker keeps its type's own ceiling (one year).
|
|
32
|
+
*/
|
|
33
|
+
maxYears?: number
|
|
28
34
|
notice?: string
|
|
29
35
|
url?: string
|
|
30
36
|
// Edited: Ferjolt Ozuni - Date: 2026-08-01
|
package/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import BlogItem from './BlogItem/BlogItem';
|
|
|
8
8
|
import Breadcrumbs from './Breadcrumbs/Breadcrumbs';
|
|
9
9
|
import Button from './Button/Button';
|
|
10
10
|
import Calendar from './Calendar/Calendar';
|
|
11
|
+
import SearchableSelect from './SearchableSelect/SearchableSelect';
|
|
11
12
|
import ChangeLanguage from './ChangeLanguage/ChangeLanguage';
|
|
12
13
|
import ChooseSeat from './Seats/ChooseSeat';
|
|
13
14
|
import CompanyItem from './CompanyItem/CompanyItem';
|
|
@@ -60,6 +61,7 @@ export {
|
|
|
60
61
|
Breadcrumbs,
|
|
61
62
|
Button,
|
|
62
63
|
Calendar,
|
|
64
|
+
SearchableSelect,
|
|
63
65
|
ChangeLanguage,
|
|
64
66
|
ChooseSeat,
|
|
65
67
|
CompanyItem,
|