@autobusal/common 1.34.0 → 1.35.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.
- package/CHANGELOG.md +9 -0
- package/Calendar/Calendar.tsx +19 -3
- package/Meta.tsx +68 -57
- package/RouteFeature/RouteFeature.tsx +21 -2
- package/RouteFeature/styles.ts +44 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.35.0 (2026-08-29)
|
|
4
|
+
|
|
5
|
+
- RouteFeature's compact chip draws a real tooltip on hover AND on focus, so a tap reveals the amenity name; `title` alone was invisible on touch and slow on desktop.
|
|
6
|
+
- Calendar treats an unparseable defaultValue as nothing chosen, and anchors an empty picker on its minDate - so a floored picker (the return leg) opens on the month being travelled rather than on today or on January.
|
|
7
|
+
|
|
8
|
+
## 1.34.1 (2026-08-29)
|
|
9
|
+
|
|
10
|
+
- 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).
|
|
11
|
+
|
|
3
12
|
## 1.34.0 (2026-08-29)
|
|
4
13
|
|
|
5
14
|
- 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.
|
package/Calendar/Calendar.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import { useState, useRef } from 'react';
|
|
|
2
2
|
import { UseFormRegister, UseFormSetValue } from 'react-hook-form';
|
|
3
3
|
import { TFunction } from 'i18next';
|
|
4
4
|
import { useOutside } from '@autobusal/hooks';
|
|
5
|
-
import { Validate, prepareDate } from '@autobusal/utilities';
|
|
5
|
+
import { Validate, prepareDate, isPreparedDate } from '@autobusal/utilities';
|
|
6
6
|
import Picker from './Picker';
|
|
7
7
|
import { Container } from './styles';
|
|
8
8
|
|
|
@@ -68,8 +68,18 @@ const Calendar = ({ type, name, label, id, defaultValue, t, validation, minDate,
|
|
|
68
68
|
* Empty instead, so the field reads as unanswered and the API's
|
|
69
69
|
* `required|date_format:d/m/Y|before:today` says so if it is left that way.
|
|
70
70
|
*/
|
|
71
|
+
/*
|
|
72
|
+
* Edited: Claude - Date: 2026-08-29
|
|
73
|
+
*
|
|
74
|
+
* A defaultValue that is not a date is NOTHING CHOSEN, not a value.
|
|
75
|
+
* `??` only catches null/undefined, so the search URL's literal "none"
|
|
76
|
+
* (what it carries for "no return leg") came through as a real value:
|
|
77
|
+
* the input displayed the word, and the picker built its grid from
|
|
78
|
+
* createDate('none') - an Invalid Date - so the round-trip calendar
|
|
79
|
+
* opened on January instead of the month being travelled.
|
|
80
|
+
*/
|
|
71
81
|
const [ prepared, setPrepared ] = useState<string>(
|
|
72
|
-
defaultValue
|
|
82
|
+
isPreparedDate(defaultValue) ? String(defaultValue) : (type === 'dob' ? '' : prepareDate(new Date()))
|
|
73
83
|
);
|
|
74
84
|
const [ selected, setSelected ] = useState<string>(prepared);
|
|
75
85
|
|
|
@@ -99,7 +109,13 @@ const Calendar = ({ type, name, label, id, defaultValue, t, validation, minDate,
|
|
|
99
109
|
// is an Invalid Date and the grid built from it renders as nothing.
|
|
100
110
|
// The anchor is display only - what the form submits stays empty
|
|
101
111
|
// until a day is actually clicked.
|
|
102
|
-
|
|
112
|
+
/*
|
|
113
|
+
* Edited: Claude - Date: 2026-08-29: with nothing chosen, open on
|
|
114
|
+
* the FLOOR when there is one - the return leg's floor is the
|
|
115
|
+
* outbound's date, so its picker opens on the month being
|
|
116
|
+
* travelled rather than on today.
|
|
117
|
+
*/
|
|
118
|
+
value={ prepared || (isPreparedDate(minDate) ? String(minDate) : prepareDate(new Date())) }
|
|
103
119
|
selected={ selected }
|
|
104
120
|
minDate={ minDate }
|
|
105
121
|
maxYears={ maxYears }
|
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
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Container, Image, Name } from './styles';
|
|
1
|
+
import { Container, Image, Name, Tooltip } from './styles';
|
|
2
2
|
import { FeatureData } from '@autobusal/providers/types/routes';
|
|
3
3
|
|
|
4
4
|
interface Props {
|
|
@@ -16,14 +16,33 @@ interface Props {
|
|
|
16
16
|
compact?: boolean
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Edited: Claude - Date: 2026-08-29
|
|
21
|
+
*
|
|
22
|
+
* THE NAME IS DRAWN, not merely promised to the browser.
|
|
23
|
+
*
|
|
24
|
+
* The compact chip carried the amenity name in `title` alone, which is the
|
|
25
|
+
* one tooltip mechanism nobody can rely on: desktop browsers wait about a
|
|
26
|
+
* second before drawing it, and touch devices never draw it at all - so on
|
|
27
|
+
* a phone these icons were unlabelled decoration with no way to find out
|
|
28
|
+
* what they meant. The title stays (it is what screen readers and the
|
|
29
|
+
* native tooltip use) and a real one is drawn on hover AND on focus, which
|
|
30
|
+
* a tap produces through tabIndex.
|
|
31
|
+
*/
|
|
19
32
|
const RouteFeature = ({ item, compact = false }: Props): JSX.Element => (
|
|
20
|
-
<Container
|
|
33
|
+
<Container
|
|
34
|
+
$compact={ compact }
|
|
35
|
+
title={ item.name }
|
|
36
|
+
tabIndex={ compact ? 0 : undefined }
|
|
37
|
+
>
|
|
21
38
|
{ /* alt was missing entirely before. It matters more now: with the
|
|
22
39
|
label hidden the image IS the content, so without it a screen
|
|
23
40
|
reader announces nothing at all for each amenity. */ }
|
|
24
41
|
<Image src={ item.image_url } alt={ item.name } />
|
|
25
42
|
|
|
26
43
|
{ !compact && <Name>{ item.name }</Name> }
|
|
44
|
+
|
|
45
|
+
{ compact && <Tooltip role="tooltip">{ item.name }</Tooltip> }
|
|
27
46
|
</Container>
|
|
28
47
|
);
|
|
29
48
|
|
package/RouteFeature/styles.ts
CHANGED
|
@@ -19,8 +19,24 @@ export const Container = styled.div<{ $compact?: boolean }>`
|
|
|
19
19
|
* template literal, where a backtick terminates the string.)
|
|
20
20
|
*/
|
|
21
21
|
${ props => props.$compact && css`
|
|
22
|
+
position: relative;
|
|
22
23
|
padding: 5px;
|
|
23
24
|
cursor: help;
|
|
25
|
+
|
|
26
|
+
/* Claude - 2026-08-29: the tooltip below is positioned against this
|
|
27
|
+
chip, and appears on hover or on focus - a tap focuses it, which is
|
|
28
|
+
the only way a touch device can ever reveal an icon's meaning. */
|
|
29
|
+
&:hover > span,
|
|
30
|
+
&:focus > span,
|
|
31
|
+
&:focus-within > span {
|
|
32
|
+
opacity: 1;
|
|
33
|
+
visibility: visible;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
&:focus {
|
|
37
|
+
outline: 2px solid ${ props => props.theme.primary.normal };
|
|
38
|
+
outline-offset: 2px;
|
|
39
|
+
}
|
|
24
40
|
` }
|
|
25
41
|
`;
|
|
26
42
|
|
|
@@ -32,3 +48,31 @@ export const Image = styled.img`
|
|
|
32
48
|
export const Name = styled.span`
|
|
33
49
|
font-size: ${ props => props.theme.size.xs };
|
|
34
50
|
`;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The amenity's name, drawn above its chip.
|
|
54
|
+
*
|
|
55
|
+
* Claude - Date: 2026-08-29
|
|
56
|
+
*
|
|
57
|
+
* `visibility` alongside `opacity` so the label is genuinely inert while
|
|
58
|
+
* hidden - a transparent tooltip still takes the pointer, and one sitting
|
|
59
|
+
* over the chip below it would eat that chip's own hover.
|
|
60
|
+
*/
|
|
61
|
+
export const Tooltip = styled.span`
|
|
62
|
+
position: absolute;
|
|
63
|
+
bottom: calc(100% + 6px);
|
|
64
|
+
left: 50%;
|
|
65
|
+
z-index: 20;
|
|
66
|
+
transform: translateX(-50%);
|
|
67
|
+
padding: 4px 8px;
|
|
68
|
+
font-size: ${ props => props.theme.size.xs };
|
|
69
|
+
line-height: 1.4;
|
|
70
|
+
white-space: nowrap;
|
|
71
|
+
color: ${ props => props.theme.primary.contrast };
|
|
72
|
+
background: ${ props => props.theme.primary.normal };
|
|
73
|
+
border-radius: ${ props => props.theme.borderRadius };
|
|
74
|
+
opacity: 0;
|
|
75
|
+
visibility: hidden;
|
|
76
|
+
pointer-events: none;
|
|
77
|
+
transition: opacity 0.2s ease;
|
|
78
|
+
`;
|