@communitiesuk/svelte-component-library 0.1.19-beta.1 → 0.1.19-beta.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.
@@ -2,6 +2,8 @@
2
2
  import { scaleLinear } from "d3-scale";
3
3
  import PositionChartAxis from "./PositionChartAxis.svelte";
4
4
  import ValueLabel from "../line-chart/ValueLabel.svelte";
5
+ import Button from "../../ui/Button.svelte";
6
+ import InsetText from "../../content/InsetText.svelte";
5
7
  let {
6
8
  value = undefined,
7
9
  min = undefined,
@@ -20,6 +22,8 @@
20
22
  annotation = undefined,
21
23
  showIcon = false,
22
24
  moreInfo = undefined,
25
+ markerRadius = chartHeight / 2,
26
+ options = [],
23
27
  rowData = [
24
28
  {
25
29
  value: value,
@@ -117,16 +121,12 @@
117
121
  }),
118
122
  );
119
123
 
120
- $inspect(allDataNormalized);
121
-
122
- // holds open/closed state for each label
123
- let openStates = $state({});
124
+ let moreInfoTogglesArray = $state(
125
+ Array.from({ length: options.length }, () => false),
126
+ );
124
127
 
125
- function toggle(label) {
126
- openStates = {
127
- ...openStates,
128
- [label]: !openStates[label], // flip true/false
129
- };
128
+ function updateMoreInfoTogglesArray(index) {
129
+ moreInfoTogglesArray[index] = !moreInfoTogglesArray[index];
130
130
  }
131
131
 
132
132
  let showLabel = $derived(
@@ -224,9 +224,6 @@
224
224
  return result;
225
225
  }
226
226
 
227
- // the 'marker' is the circle
228
- let markerRadius = $derived(chartHeight / 2);
229
-
230
227
  // the 'bar' is the 10 rectangles side by side
231
228
  let barWidth = $derived(chartWidth - markerRadius * 2);
232
229
  let barHeight = $derived((chartHeight * 5) / 6);
@@ -308,13 +305,22 @@
308
305
  >
309
306
  {#each allDataNormalized as positionChart, i}
310
307
  {#if showLabel}
311
- <p class="label">{positionChart.label}</p>
308
+ <p
309
+ class="govuk-body-s"
310
+ style=" text-align: right;
311
+ margin: 0;
312
+ line-height: 1.05;"
313
+ >
314
+ {positionChart.label}
315
+ </p>
312
316
  {/if}
313
317
  {#if showIcon}
314
- <button
315
- id="info-{positionChart.label}"
316
- onclick={() => toggle(positionChart.label)}>ⓘ</button
317
- >
318
+ <Button
319
+ textContent="i"
320
+ buttonType="moreInfo"
321
+ noPadding={true}
322
+ onClickFunction={() => updateMoreInfoTogglesArray(i)}
323
+ ></Button>
318
324
  {/if}
319
325
  <div
320
326
  class="chart"
@@ -340,70 +346,68 @@
340
346
  >{/each}
341
347
  {#each Object.entries(positionChart.rowData) as [tier, points]}
342
348
  {#each points as rowValue, i}
343
- {@const markerId = "marker-" + rowValue.value}
344
- <g
345
- data-id={markerId}
346
- onclick={interactiveMarkers
347
- ? (event) => onClickMarker(event, rowValue, markerId)
348
- : null}
349
- onmouseenter={interactiveMarkers
350
- ? (event) =>
351
- onMouseEnterMarker(
352
- event,
353
- rowValue,
354
- markerId,
355
- event.currentTarget.getBoundingClientRect(),
356
- )
357
- : null}
358
- onmouseleave={interactiveMarkers
359
- ? (event) => onMouseLeaveMarker(event, rowValue, markerId)
360
- : null}
361
- role="button"
362
- tabindex="0"
363
- onkeydown={interactiveMarkers
364
- ? (e) => e.key === "Enter" && onClickMarker(e, value)
365
- : null}
366
- pointer-events={interactiveMarkers ? null : "none"}
367
- transform="translate({xFunction(
368
- positionChart.min,
369
- positionChart.max,
370
- )(rowValue.value) + markerRadius},{positionChart.chartHeight /
371
- 2})"
372
- >
373
- <circle
374
- r={markerRadius}
375
- cx="0"
376
- cy="0"
377
- fill={rowValue.colour}
378
- stroke="white"
379
- opacity={rowValue.opacity}
380
- ></circle>
381
- </g>
349
+ {#if !isNaN(Number(rowValue.value))}
350
+ {@const markerId = "marker-" + rowValue.value}
351
+ <g
352
+ data-id={markerId}
353
+ onclick={interactiveMarkers
354
+ ? (event) => onClickMarker(event, rowValue, markerId)
355
+ : null}
356
+ onmouseenter={interactiveMarkers
357
+ ? (event) =>
358
+ onMouseEnterMarker(
359
+ event,
360
+ rowValue,
361
+ markerId,
362
+ event.currentTarget.getBoundingClientRect(),
363
+ )
364
+ : null}
365
+ onmouseleave={interactiveMarkers
366
+ ? (event) => onMouseLeaveMarker(event, rowValue, markerId)
367
+ : null}
368
+ role="button"
369
+ tabindex="0"
370
+ onkeydown={interactiveMarkers
371
+ ? (e) => e.key === "Enter" && onClickMarker(e, value)
372
+ : null}
373
+ pointer-events={interactiveMarkers ? null : "none"}
374
+ transform="translate({xFunction(
375
+ positionChart.min,
376
+ positionChart.max,
377
+ )(rowValue.value) + markerRadius},{positionChart.chartHeight /
378
+ 2})"
379
+ >
380
+ <circle
381
+ r={markerRadius}
382
+ cx="0"
383
+ cy="0"
384
+ fill={rowValue.colour}
385
+ stroke="white"
386
+ opacity={rowValue.opacity}
387
+ ></circle>
388
+ </g>
389
+ {/if}
382
390
  {/each}
383
391
  {/each}
384
392
  </svg>
385
393
  </div>
386
- {#if openStates[positionChart.label]}
387
- <div
388
- class="accordion"
389
- style="grid-column:1 / -1; background-color:lightgrey; padding:10px; margin-right:{markerRadius}px"
390
- >
391
- <p>
392
- {positionChart.moreInfo}
393
- </p>
394
+ {#if moreInfoTogglesArray[i]}
395
+ <div class="accordion" style="grid-column:1 / -1;">
396
+ <p class="govuk-body-s">{positionChart.moreInfo}</p>
397
+ <!-- <InsetText content={positionChart.moreInfo} renderStringAsHTML={true}
398
+ ></InsetText> -->
394
399
  </div>
395
400
  {/if}
396
401
  {#if positionChart.divider}
397
402
  <div style="grid-column:1 / -1">
398
- <svg width="100%" height="10">
403
+ <svg width="100%" height="5">
399
404
  <line
400
405
  x1="0"
401
- y1="5"
406
+ y1="2.5"
402
407
  x2="100%"
403
- y2="5"
404
- stroke="black"
405
- stroke-width="2"
406
- stroke-dasharray="2,6"
408
+ y2="2.55"
409
+ stroke="grey"
410
+ stroke-width="0.5"
407
411
  ></line>
408
412
  </svg>
409
413
  </div>{/if}
@@ -445,11 +449,6 @@
445
449
  column-gap: 2%;
446
450
  row-gap: 2%;
447
451
  }
448
- .label {
449
- text-align: right;
450
- margin: 0;
451
- line-height: 1.05;
452
- }
453
452
  .chart {
454
453
  display: flex;
455
454
  flex-direction: column;
@@ -21,6 +21,8 @@ declare const PositionChart: import("svelte").Component<{
21
21
  annotation?: any;
22
22
  showIcon?: boolean;
23
23
  moreInfo?: any;
24
+ markerRadius?: any;
25
+ options?: any[];
24
26
  rowData?: any[];
25
27
  allData?: any[];
26
28
  markerStyles?: Record<string, any>;
@@ -59,6 +61,8 @@ type $$ComponentProps = {
59
61
  annotation?: any;
60
62
  showIcon?: boolean;
61
63
  moreInfo?: any;
64
+ markerRadius?: any;
65
+ options?: any[];
62
66
  rowData?: any[];
63
67
  allData?: any[];
64
68
  markerStyles?: Record<string, any>;
@@ -94,4 +94,8 @@
94
94
  margin: 0;
95
95
  line-height: 1;
96
96
  }
97
+
98
+ a {
99
+ color: #1d70b8;
100
+ }
97
101
  </style>
@@ -57,6 +57,10 @@
57
57
  homepage?: boolean;
58
58
  minLength?: number;
59
59
  required?: boolean;
60
+ autoselect?: boolean; // Auto-highlight first suggestion
61
+ hideHint?: boolean; // Hide the hint input element when autoselect is true
62
+ prefixMatchOnly?: boolean; // Only show suggestions that start with the query
63
+ autoFocusSubmitOnSelection?: boolean; // Auto-focus submit button when selection is confirmed
60
64
  [key: string]: any; // Allow other props
61
65
  };
62
66
 
@@ -83,6 +87,10 @@
83
87
  homepage = false,
84
88
  minLength = 2,
85
89
  required = false,
90
+ autoselect = true,
91
+ hideHint = false,
92
+ prefixMatchOnly = false,
93
+ autoFocusSubmitOnSelection = false,
86
94
  ...restProps
87
95
  }: Props = $props();
88
96
 
@@ -222,6 +230,10 @@
222
230
  homepage,
223
231
  minLength,
224
232
  required,
233
+ autoselect,
234
+ hideHint,
235
+ prefixMatchOnly,
236
+ autoFocusSubmitOnSelection,
225
237
  ...restProps,
226
238
  });
227
239
  </script>
@@ -31,6 +31,10 @@ type Props = {
31
31
  homepage?: boolean;
32
32
  minLength?: number;
33
33
  required?: boolean;
34
+ autoselect?: boolean;
35
+ hideHint?: boolean;
36
+ prefixMatchOnly?: boolean;
37
+ autoFocusSubmitOnSelection?: boolean;
34
38
  [key: string]: any;
35
39
  };
36
40
  declare const PostcodeOrAreaSearch: import("svelte").Component<Props, {}, "selectedValue">;
@@ -51,6 +51,10 @@
51
51
  hint?: string; // Add hint prop
52
52
  selectedValue?: any; // Bindable selected value, updated on selection
53
53
  maxSuggestions?: number; // Maximum number of suggestions to display
54
+ autoselect?: boolean; // Auto-highlight first suggestion
55
+ hideHint?: boolean; // Hide the hint input element when autoselect is true
56
+ prefixMatchOnly?: boolean; // Only show suggestions that start with the query (better for hint behavior)
57
+ autoFocusSubmitOnSelection?: boolean; // Auto-focus submit button when selection is confirmed
54
58
  };
55
59
 
56
60
  let {
@@ -85,6 +89,10 @@
85
89
  hint = undefined, // Add hint destructuring
86
90
  selectedValue = $bindable(), // Bindable prop for selected value
87
91
  maxSuggestions = undefined, // Maximum number of suggestions to display
92
+ autoselect = true, // Default to false as per library default
93
+ hideHint = false, // Default to false - show hint by default
94
+ prefixMatchOnly = false, // Default to false - show all matches
95
+ autoFocusSubmitOnSelection = false, // Default to false - don't auto-focus by default
88
96
  ...restSearchProps // Other props for the base Search component
89
97
  }: Props = $props();
90
98
 
@@ -97,6 +105,8 @@
97
105
  let currentSourceKey = $state<string | undefined>(undefined);
98
106
  let currentSourceProperty = $state<string | undefined>(undefined);
99
107
 
108
+
109
+
100
110
  // --- Derived Values ---
101
111
  const wrapperClasses = $derived(
102
112
  clsx(
@@ -254,19 +264,50 @@
254
264
  populateResults([]);
255
265
  return;
256
266
  }
267
+
257
268
  const lowerQuery = query.toLowerCase();
258
- const filtered = options.filter((option) => {
259
- const label = typeof option === "string" ? option : option.label;
260
- return label.toLowerCase().includes(lowerQuery);
261
- });
262
269
 
263
- // Apply maxSuggestions limit if specified
264
- const limitedResults =
265
- maxSuggestions && maxSuggestions > 0
266
- ? filtered.slice(0, maxSuggestions)
267
- : filtered;
270
+ if (prefixMatchOnly) {
271
+ // Only show suggestions that start with the query
272
+ const filtered = options.filter((option) => {
273
+ const label = typeof option === "string" ? option : option.label;
274
+ return label.toLowerCase().startsWith(lowerQuery);
275
+ });
276
+
277
+ // Apply maxSuggestions limit if specified
278
+ const limitedResults =
279
+ maxSuggestions && maxSuggestions > 0
280
+ ? filtered.slice(0, maxSuggestions)
281
+ : filtered;
282
+
283
+ populateResults(limitedResults);
284
+ } else {
285
+ // Split results into two groups: starts-with and contains (existing behavior)
286
+ const startsWithResults: Suggestion[] = [];
287
+ const containsResults: Suggestion[] = [];
288
+
289
+ options.forEach((option) => {
290
+ const label = typeof option === "string" ? option : option.label;
291
+ const lowerLabel = label.toLowerCase();
292
+
293
+ if (lowerLabel.startsWith(lowerQuery)) {
294
+ startsWithResults.push(option);
295
+ } else if (lowerLabel.includes(lowerQuery)) {
296
+ containsResults.push(option);
297
+ }
298
+ });
299
+
300
+ // Combine results: starts-with first (for better hint behavior), then contains
301
+ const filtered = [...startsWithResults, ...containsResults];
268
302
 
269
- populateResults(limitedResults);
303
+ // Apply maxSuggestions limit if specified
304
+ const limitedResults =
305
+ maxSuggestions && maxSuggestions > 0
306
+ ? filtered.slice(0, maxSuggestions)
307
+ : filtered;
308
+
309
+ populateResults(limitedResults);
310
+ }
270
311
  };
271
312
 
272
313
  // Determine which source to use
@@ -297,6 +338,7 @@
297
338
  const dynamicSourceFunction = sourceSelector
298
339
  ? (query: string, populateResults: (results: Suggestion[]) => void) => {
299
340
  const selectedSource = sourceSelector(query, options || []);
341
+
300
342
  // Handle invalid returns by falling back to default logic
301
343
  if (selectedSource === "api") {
302
344
  getResultsFromApi(query, populateResults);
@@ -366,7 +408,12 @@
366
408
  // Define confirm function
367
409
  let isSubmitting = false; // Prevent double submit
368
410
  const handleConfirm = (confirmedValue: Suggestion | undefined) => {
369
- if (confirmedValue === undefined || isSubmitting) return;
411
+ console.log('handleConfirm called with:', confirmedValue, 'isSubmitting:', isSubmitting); // Debug log
412
+
413
+ if (confirmedValue === undefined) return;
414
+
415
+ // Reset submitting flag at the start of each new confirmation
416
+ isSubmitting = false;
370
417
 
371
418
  // Re-assign selectedValue before any form-based guard checks (!form) so bindings still update
372
419
  // (e.g. when no <form> exists around the component usage) and search component value is being used clienside without a page reload
@@ -379,23 +426,37 @@
379
426
  const inputElement =
380
427
  autocompleteInstance?.inputElement as HTMLInputElement;
381
428
  const form = containerElement?.closest("form");
382
-
383
- if (!inputElement || !form) return;
384
-
385
- isSubmitting = true;
386
- inputElement.value = inputValueTemplate(confirmedValue);
387
- inputElement.dataset.autocompleteAccepted = "true"; // Set tracking attribute
388
-
389
- // Submit form
390
- if (form.requestSubmit) {
391
- form.requestSubmit();
392
- } else {
393
- form.submit(); // Fallback for older browsers
429
+ const submitButton = containerElement?.querySelector('button[type="submit"]') as HTMLButtonElement;
430
+
431
+ console.log('Submit button found:', !!submitButton); // Debug log
432
+
433
+ // Always focus the submit button first, regardless of form presence (if feature is enabled)
434
+ if (autoFocusSubmitOnSelection && submitButton) {
435
+ console.log('Focusing submit button'); // Debug log
436
+ // Use requestAnimationFrame to ensure the focus happens after DOM updates
437
+ requestAnimationFrame(() => {
438
+ submitButton.focus();
439
+ console.log('Submit button focused, document.activeElement:', document.activeElement === submitButton);
440
+ });
394
441
  }
395
- // Reset flag after a short delay in case submission fails/is prevented
396
- setTimeout(() => {
442
+
443
+ // Handle form submission separately
444
+ if (inputElement && form) {
445
+ isSubmitting = true;
446
+ inputElement.value = inputValueTemplate(confirmedValue);
447
+ inputElement.dataset.autocompleteAccepted = "true"; // Set tracking attribute
448
+
449
+ // Submit form immediately
450
+ console.log('Submitting form'); // Debug log
451
+ if (form.requestSubmit) {
452
+ form.requestSubmit();
453
+ } else {
454
+ form.submit(); // Fallback for older browsers
455
+ }
456
+
457
+ // Reset flag after submission
397
458
  isSubmitting = false;
398
- }, 500);
459
+ }
399
460
  };
400
461
 
401
462
  // Initialise accessible-autocomplete
@@ -406,6 +467,8 @@
406
467
  inputClasses: searchInput.classList, // Pass original classes directly
407
468
  source: finalSourceFunction,
408
469
  minLength: minLength,
470
+ autoselect: autoselect,
471
+ hintClasses: hideHint ? "hidden-hint" : "",
409
472
  confirmOnBlur: confirmOnBlur,
410
473
  showNoOptionsFound: showNoOptionsFound,
411
474
  defaultValue: defaultValue,
@@ -430,10 +493,8 @@
430
493
  const autocompleteInputElement = containerElement?.querySelector(
431
494
  ".gem-c-search-with-autocomplete__input",
432
495
  ) as HTMLInputElement | null;
433
- // console.log(
434
- // "SearchAutocomplete: Input element queried from DOM:",
435
- // autocompleteInputElement,
436
- // ); // Updated log
496
+
497
+ // Apply hint visibility classes via hintClasses API
437
498
 
438
499
  // Post-initialisation tweaks
439
500
  if (autocompleteInputElement) {
@@ -446,6 +507,13 @@
446
507
  // Listen for input changes on the autocomplete field
447
508
  autocompleteInputElement.addEventListener("input", () => {
448
509
  const val = autocompleteInputElement.value;
510
+
511
+ // Reset isSubmitting flag when user starts typing again
512
+ if (isSubmitting) {
513
+ console.log('User typing, resetting isSubmitting flag');
514
+ isSubmitting = false;
515
+ }
516
+
449
517
  // Remove any existing 'too-short' warning before adding a new one to ensure we don't accumulate multiple warning items.
450
518
  suggestionsMenu
451
519
  ?.querySelector(
@@ -539,6 +607,64 @@
539
607
  position: relative;
540
608
  }
541
609
 
610
+ /* Hide hint when requested via hintClasses - must come AFTER the default styling for proper cascade */
611
+ .gem-c-search-with-autocomplete
612
+ .gem-c-search-with-autocomplete__hint.hidden-hint {
613
+ display: none !important;
614
+ visibility: hidden !important;
615
+ }
616
+
617
+ /* Default hint styling when visible - ensure smooth overlapping with main input */
618
+ .gem-c-search-with-autocomplete .gem-c-search-with-autocomplete__hint {
619
+ /* Use identical positioning and sizing as main input */
620
+ position: absolute !important;
621
+ top: 0 !important;
622
+ left: 0 !important;
623
+ z-index: 99 !important;
624
+
625
+ /* Visual styling for hint */
626
+ color: rgba(0, 0, 0, 0.4);
627
+ background: transparent;
628
+ pointer-events: none;
629
+
630
+ /* Copy EXACT styling from main input */
631
+ margin: 0;
632
+ width: 100%;
633
+ height: 2.1052631579em;
634
+ padding: 0.3157894737em;
635
+ border: 2px solid transparent; /* Transparent instead of visible */
636
+ border-radius: 0;
637
+ box-sizing: border-box;
638
+ -webkit-appearance: none;
639
+ -moz-appearance: none;
640
+ appearance: none;
641
+ font-family: "GDS Transport", arial, sans-serif;
642
+ -webkit-font-smoothing: antialiased;
643
+ -moz-osx-font-smoothing: grayscale;
644
+ font-weight: 400;
645
+ font-size: 1.1875rem;
646
+ line-height: 1.4736842105;
647
+
648
+ /* Text alignment */
649
+ text-align: left;
650
+ white-space: nowrap;
651
+ overflow: hidden;
652
+
653
+ /* Ensure visibility - but allow hidden-hint to override */
654
+ display: block;
655
+ visibility: visible;
656
+ opacity: 1;
657
+ }
658
+
659
+ /* Custom focus styles for submit button (when focused after autocomplete selection) */
660
+ .gem-c-search-with-autocomplete .gem-c-search__submit:focus {
661
+ background-color: #ffdd00; /* GDS focus yellow */
662
+ border-color: #0b0c0c; /* GDS text color for contrast */
663
+ color: #0b0c0c; /* Dark text on yellow background */
664
+ outline: 3px solid #ffdd00;
665
+ outline-offset: 0;
666
+ }
667
+
542
668
  .gem-c-search-with-autocomplete__menu {
543
669
  margin: 0;
544
670
  padding: 0;
@@ -36,6 +36,10 @@ type Props = {
36
36
  hint?: string;
37
37
  selectedValue?: any;
38
38
  maxSuggestions?: number;
39
+ autoselect?: boolean;
40
+ hideHint?: boolean;
41
+ prefixMatchOnly?: boolean;
42
+ autoFocusSubmitOnSelection?: boolean;
39
43
  };
40
44
  declare const SearchAutocomplete: import("svelte").Component<Props, {}, "selectedValue">;
41
45
  type SearchAutocomplete = ReturnType<typeof SearchAutocomplete>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@communitiesuk/svelte-component-library",
3
- "version": "0.1.19-beta.1",
3
+ "version": "0.1.19-beta.3",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/communitiesuk/mhclg_svelte_component_library.git"