@autobusal/common 1.34.1 → 1.36.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 CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.0 (2026-08-29)
4
+
5
+ - Editors offer Save & Close alongside Save & Stay, so correcting a record no longer bounces you back to its list every time (opt-in per page, editing only). The pair is driven from here (Viewer + Actions).
6
+
7
+ ## 1.35.0 (2026-08-29)
8
+
9
+ - 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.
10
+ - 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.
11
+
3
12
  ## 1.34.1 (2026-08-29)
4
13
 
5
14
  - 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).
@@ -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 ?? (type === 'dob' ? '' : prepareDate(new Date()))
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
- value={ prepared || prepareDate(new Date()) }
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 }
@@ -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 $compact={ compact } title={ item.name }>
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
 
@@ -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
+ `;
@@ -19,6 +19,25 @@ interface Props {
19
19
  const Actions = ({ id, available, pending, confirm, t, onDelete }: Props): JSX.Element => {
20
20
  const options: ActionData[] = [];
21
21
 
22
+ /**
23
+ * Edited: Claude - Date: 2026-08-29
24
+ *
25
+ * Two ways to save, when the page asks for them (actions={['save',
26
+ * 'save_stay', ...]}): one that returns to the list this record came
27
+ * from, one that keeps it open for another edit. Saving used to always
28
+ * throw you back to the list, so correcting three fields on one record
29
+ * meant three round trips through it.
30
+ *
31
+ * Opt-in per page, because "close" has to mean something: the brand
32
+ * settings, the ticket editors and the notification preferences have no
33
+ * list to go back to and keep their single Save.
34
+ *
35
+ * ONLY WHEN EDITING. On a create form there is nothing to stay on - the
36
+ * record has no id yet, so the form would sit there still full, and the
37
+ * obvious next click would file a duplicate.
38
+ */
39
+ const paired = available.includes('save_stay') && id > 0;
40
+
22
41
  const onAskDelete = (): void => {
23
42
  if (window.confirm(confirm ?? t('table.confirm', { ns: 'common' }))) {
24
43
  if (onDelete) {
@@ -37,7 +56,9 @@ const Actions = ({ id, available, pending, confirm, t, onDelete }: Props): JSX.E
37
56
 
38
57
  if (available.includes('save')) {
39
58
  options.push({
40
- label: t('table.actions.save', { ns: 'common' }),
59
+ // with a second save button beside it, "Save" no longer says what
60
+ // THIS one does
61
+ label: t(paired ? 'table.actions.save_close' : 'table.actions.save', { ns: 'common' }),
41
62
  icon: <RiSave2Line />,
42
63
  type: 'submit'
43
64
  });
@@ -68,12 +89,24 @@ const Actions = ({ id, available, pending, confirm, t, onDelete }: Props): JSX.E
68
89
 
69
90
  if (available.includes('update')) {
70
91
  options.push({
71
- label: t('table.actions.update', { ns: 'common' }),
92
+ label: t(paired ? 'table.actions.save_close' : 'table.actions.update', { ns: 'common' }),
72
93
  icon: <BiEditAlt />,
73
94
  type: 'submit'
74
95
  });
75
96
  }
76
97
 
98
+ // second, so the first submit button - the one Enter presses - stays the
99
+ // one that has always been there
100
+ if (paired) {
101
+ options.push({
102
+ label: t('table.actions.save_stay', { ns: 'common' }),
103
+ icon: <RiSave2Line />,
104
+ type: 'submit',
105
+ name: 'intent',
106
+ value: 'stay'
107
+ });
108
+ }
109
+
77
110
  if (available.includes('approve')) {
78
111
  options.push({
79
112
  label: t('table.actions.approve', { ns: 'common' }),
package/Viewer/Viewer.tsx CHANGED
@@ -21,7 +21,15 @@ interface Props {
21
21
  */
22
22
  confirm?: string
23
23
  t: TFunction<'common'>
24
- onSave?: (data: any) => void
24
+ /**
25
+ * Edited: Claude - Date: 2026-08-29
26
+ *
27
+ * `stay` is true when the editor was saved with "Save & Stay" rather than
28
+ * "Save & Close" - the page keeps the record open instead of returning to
29
+ * its list. Optional, so a form that never navigates anyway (the brand
30
+ * settings, the ticket editors) needs no change and offers one button.
31
+ */
32
+ onSave?: (data: any, stay?: boolean) => void
25
33
  onDelete?: (data?: any) => void
26
34
  // Edited: Ferjolt Ozuni - Date: 2026-07-31
27
35
  // Optional second submit-type action (actions={['save', 'test']}) - e.g.
@@ -62,7 +70,10 @@ const Viewer = ({ id, type, data, fetching, pending, actions, confirm, t, onSave
62
70
  }
63
71
 
64
72
  if (onSave !== undefined) {
65
- onSave(data);
73
+ // 'stay' is set by the second save button (see Actions.tsx); every
74
+ // other submitter - including plain Enter, which fires the FIRST
75
+ // button - leaves it undefined and the page closes as it always has
76
+ onSave(data, submitter?.value === 'stay');
66
77
  }
67
78
  };
68
79
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/common",
3
- "version": "1.34.1",
3
+ "version": "1.36.0",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"