svelte-multiselect 11.1.1 → 11.2.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.
Files changed (42) hide show
  1. package/dist/CircleSpinner.svelte +2 -1
  2. package/dist/CmdPalette.svelte +13 -7
  3. package/dist/CmdPalette.svelte.d.ts +4 -5
  4. package/dist/CodeExample.svelte +80 -0
  5. package/dist/CodeExample.svelte.d.ts +22 -0
  6. package/dist/CopyButton.svelte +47 -0
  7. package/dist/CopyButton.svelte.d.ts +25 -0
  8. package/dist/FileDetails.svelte +53 -0
  9. package/dist/FileDetails.svelte.d.ts +21 -0
  10. package/dist/GitHubCorner.svelte +82 -0
  11. package/dist/GitHubCorner.svelte.d.ts +13 -0
  12. package/dist/Icon.svelte +23 -0
  13. package/dist/Icon.svelte.d.ts +8 -0
  14. package/dist/MultiSelect.svelte +129 -75
  15. package/dist/MultiSelect.svelte.d.ts +2 -3
  16. package/dist/PrevNext.svelte +100 -0
  17. package/dist/PrevNext.svelte.d.ts +48 -0
  18. package/dist/RadioButtons.svelte +67 -0
  19. package/dist/RadioButtons.svelte.d.ts +44 -0
  20. package/dist/Toggle.svelte +78 -0
  21. package/dist/Toggle.svelte.d.ts +16 -0
  22. package/dist/icons.d.ts +47 -0
  23. package/dist/icons.js +46 -0
  24. package/dist/index.d.ts +10 -3
  25. package/dist/index.js +10 -3
  26. package/dist/types.d.ts +141 -0
  27. package/dist/utils.d.ts +6 -22
  28. package/dist/utils.js +17 -25
  29. package/package.json +17 -35
  30. package/readme.md +287 -121
  31. package/dist/icons/ChevronExpand.svelte +0 -9
  32. package/dist/icons/ChevronExpand.svelte.d.ts +0 -4
  33. package/dist/icons/Cross.svelte +0 -10
  34. package/dist/icons/Cross.svelte.d.ts +0 -4
  35. package/dist/icons/Disabled.svelte +0 -10
  36. package/dist/icons/Disabled.svelte.d.ts +0 -4
  37. package/dist/icons/Octocat.svelte +0 -9
  38. package/dist/icons/Octocat.svelte.d.ts +0 -4
  39. package/dist/icons/index.d.ts +0 -4
  40. package/dist/icons/index.js +0 -4
  41. package/dist/props.d.ts +0 -143
  42. package/dist/props.js +0 -1
@@ -1,14 +1,13 @@
1
- <script lang="ts">import { tick } from 'svelte';
1
+ <script lang="ts">import { Icon, Wiggle } from './';
2
+ import { tick } from 'svelte';
2
3
  import { flip } from 'svelte/animate';
3
4
  import CircleSpinner from './CircleSpinner.svelte';
4
- import Wiggle from './Wiggle.svelte';
5
- import { CrossIcon, DisabledIcon, ExpandIcon } from './icons';
6
5
  import { get_label, get_style, highlight_matching_nodes } from './utils';
7
6
  let { activeIndex = $bindable(null), activeOption = $bindable(null), createOptionMsg = `Create this option...`, allowUserOptions = false, allowEmpty = false, autocomplete = `off`, autoScroll = true, breakpoint = 800, defaultDisabledTitle = `This option is disabled`, disabled = false, disabledInputTitle = `This input is disabled`, duplicateOptionMsg = `This option is already selected`, duplicates = false, key = (opt) => `${get_label(opt)}`.toLowerCase(), filterFunc = (opt, searchText) => {
8
7
  if (!searchText)
9
8
  return true;
10
9
  return `${get_label(opt)}`.toLowerCase().includes(searchText.toLowerCase());
11
- }, closeDropdownOnSelect = `desktop`, form_input = $bindable(null), highlightMatches = true, id = null, input = $bindable(null), inputClass = ``, inputStyle = null, inputmode = null, invalid = $bindable(false), liActiveOptionClass = ``, liActiveUserMsgClass = ``, liOptionClass = ``, liOptionStyle = null, liSelectedClass = ``, liSelectedStyle = null, liUserMsgClass = ``, loading = false, matchingOptions = $bindable([]), maxOptions = undefined, maxSelect = null, maxSelectMsg = (current, max) => (max > 1 ? `${current}/${max}` : ``), maxSelectMsgClass = ``, name = null, noMatchingOptionsMsg = `No matching options`, open = $bindable(false), options = $bindable(), outerDiv = $bindable(null), outerDivClass = ``, parseLabelsAsHtml = false, pattern = null, placeholder = null, removeAllTitle = `Remove all`, removeBtnTitle = `Remove`, minSelect = null, required = false, resetFilterOnAdd = true, searchText = $bindable(``), selected = $bindable(options
10
+ }, closeDropdownOnSelect = `if-mobile`, form_input = $bindable(null), highlightMatches = true, id = null, input = $bindable(null), inputClass = ``, inputStyle = null, inputmode = null, invalid = $bindable(false), liActiveOptionClass = ``, liActiveUserMsgClass = ``, liOptionClass = ``, liOptionStyle = null, liSelectedClass = ``, liSelectedStyle = null, liUserMsgClass = ``, loading = false, matchingOptions = $bindable([]), maxOptions = undefined, maxSelect = null, maxSelectMsg = (current, max) => (max > 1 ? `${current}/${max}` : ``), maxSelectMsgClass = ``, name = null, noMatchingOptionsMsg = `No matching options`, open = $bindable(false), options = $bindable(), outerDiv = $bindable(null), outerDivClass = ``, parseLabelsAsHtml = false, pattern = null, placeholder = null, removeAllTitle = `Remove all`, removeBtnTitle = `Remove`, minSelect = null, required = false, resetFilterOnAdd = true, searchText = $bindable(``), selected = $bindable(options
12
11
  ?.filter((opt) => opt instanceof Object && opt?.preselected)
13
12
  .slice(0, maxSelect ?? undefined) ?? []), sortSelected = false, selectedOptionsDraggable = !sortSelected, style = null, ulOptionsClass = ``, ulSelectedClass = ``, ulSelectedStyle = null, ulOptionsStyle = null, value = $bindable(null), expandIcon, selectedItem, children, removeIcon, afterInput, spinner, disabledIcon, option, userMsg, onblur, onclick, onfocus, onkeydown, onkeyup, onmousedown, onmouseenter, onmouseleave, ontouchcancel, ontouchend, ontouchmove, ontouchstart, onadd, oncreate, onremove, onremoveAll, onchange, onopen, onclose, portal: portal_params = {}, ...rest } = $props();
14
13
  $effect.pre(() => {
@@ -105,8 +104,9 @@ function add(option_to_add, event) {
105
104
  else {
106
105
  option_to_add = searchText; // else create custom option as string
107
106
  }
108
- oncreate?.({ option: option_to_add });
109
107
  }
108
+ // Fire oncreate event for all user-created options, regardless of type
109
+ oncreate?.({ option: option_to_add });
110
110
  if (allowUserOptions === `append`)
111
111
  options = [...options, option_to_add];
112
112
  }
@@ -135,9 +135,12 @@ function add(option_to_add, event) {
135
135
  }
136
136
  const reached_max_select = selected.length === maxSelect;
137
137
  const dropdown_should_close = closeDropdownOnSelect === true ||
138
- (closeDropdownOnSelect === `desktop` && window_width && window_width < breakpoint);
138
+ closeDropdownOnSelect === `retain-focus` ||
139
+ (closeDropdownOnSelect === `if-mobile` && window_width &&
140
+ window_width < breakpoint);
141
+ const should_retain_focus = closeDropdownOnSelect === `retain-focus`;
139
142
  if (reached_max_select || dropdown_should_close) {
140
- close_dropdown(event);
143
+ close_dropdown(event, should_retain_focus);
141
144
  }
142
145
  else if (!dropdown_should_close) {
143
146
  input?.focus();
@@ -182,9 +185,10 @@ function open_dropdown(event) {
182
185
  }
183
186
  onopen?.({ event });
184
187
  }
185
- function close_dropdown(event) {
188
+ function close_dropdown(event, retain_focus = false) {
186
189
  open = false;
187
- input?.blur();
190
+ if (!retain_focus)
191
+ input?.blur();
188
192
  activeIndex = null;
189
193
  onclose?.({ event });
190
194
  }
@@ -195,8 +199,7 @@ async function handle_keydown(event) {
195
199
  event.stopPropagation();
196
200
  close_dropdown(event);
197
201
  searchText = ``;
198
- }
199
- // on enter key: toggle active option and reset search text
202
+ } // on enter key: toggle active option and reset search text
200
203
  else if (event.key === `Enter`) {
201
204
  event.stopPropagation();
202
205
  event.preventDefault(); // prevent enter key from triggering form submission
@@ -216,8 +219,7 @@ async function handle_keydown(event) {
216
219
  // in which case enter means open it
217
220
  open_dropdown(event);
218
221
  }
219
- }
220
- // on up/down arrow keys: update active option
222
+ } // on up/down arrow keys: update active option
221
223
  else if ([`ArrowDown`, `ArrowUp`].includes(event.key)) {
222
224
  event.stopPropagation();
223
225
  // if no option is active yet, but there are matching options, make first one active
@@ -252,14 +254,12 @@ async function handle_keydown(event) {
252
254
  if (li)
253
255
  li.scrollIntoViewIfNeeded?.();
254
256
  }
255
- }
256
- // on backspace key: remove last selected option
257
+ } // on backspace key: remove last selected option
257
258
  else if (event.key === `Backspace` && selected.length > 0 && !searchText) {
258
259
  event.stopPropagation();
259
260
  // Don't prevent default, allow normal backspace behavior if not removing
260
261
  remove(selected.at(-1), event);
261
- }
262
- // make first matching option active on any keypress (if none of the above special cases match)
262
+ } // make first matching option active on any keypress (if none of the above special cases match)
263
263
  else if (matchingOptions.length > 0 && activeIndex === null) {
264
264
  // Don't stop propagation or prevent default here, allow normal character input
265
265
  activeIndex = 0;
@@ -267,10 +267,11 @@ async function handle_keydown(event) {
267
267
  }
268
268
  function remove_all(event) {
269
269
  event.stopPropagation();
270
+ selected = []; // Set selected first
271
+ searchText = ``;
272
+ // Now trigger change events
270
273
  onremoveAll?.({ options: selected });
271
274
  onchange?.({ options: selected, type: `removeAll` });
272
- selected = [];
273
- searchText = ``;
274
275
  }
275
276
  let is_selected = $derived((label) => selected.map(get_label).includes(label));
276
277
  const if_enter_or_space = (handler) => (event) => {
@@ -328,10 +329,38 @@ const handle_input_keydown = (event) => {
328
329
  onkeydown?.(event);
329
330
  };
330
331
  const handle_input_focus = (event) => {
331
- open_dropdown(event); // Internal logic
332
- // Call original forwarded handler
332
+ open_dropdown(event);
333
333
  onfocus?.(event);
334
334
  };
335
+ // Override input's focus method to ensure dropdown opens on programmatic focus
336
+ // https://github.com/janosh/svelte-multiselect/issues/289
337
+ $effect(() => {
338
+ if (!input)
339
+ return;
340
+ const orig_focus = input.focus.bind(input);
341
+ input.focus = (options) => {
342
+ orig_focus(options);
343
+ if (!disabled && !open) {
344
+ open_dropdown(new FocusEvent(`focus`, { bubbles: true }));
345
+ }
346
+ };
347
+ return () => {
348
+ if (input)
349
+ input.focus = orig_focus;
350
+ };
351
+ });
352
+ const handle_input_blur = (event) => {
353
+ // For portalled dropdowns, don't close on blur since clicks on portalled elements
354
+ // will cause blur but we want to allow the click to register first
355
+ if (portal_params?.active) {
356
+ onblur?.(event); // Let the click handler manage closing for portalled dropdowns
357
+ return;
358
+ }
359
+ // For non-portalled dropdowns, close when focus moves outside the component
360
+ if (!outerDiv?.contains(event.relatedTarget))
361
+ close_dropdown(event);
362
+ onblur?.(event); // Call original handler (if any passed as component prop)
363
+ };
335
364
  // reset form validation when required prop changes
336
365
  // https://github.com/janosh/svelte-multiselect/issues/285
337
366
  $effect.pre(() => {
@@ -342,7 +371,8 @@ function portal(node, params) {
342
371
  let { target_node, active } = params;
343
372
  if (!active)
344
373
  return;
345
- let render_in_place = typeof window === `undefined` || !document.body.contains(node);
374
+ let render_in_place = typeof window === `undefined` ||
375
+ !document.body.contains(node);
346
376
  if (!render_in_place) {
347
377
  document.body.appendChild(node);
348
378
  node.style.position = `fixed`;
@@ -368,7 +398,8 @@ function portal(node, params) {
368
398
  return {
369
399
  update(params) {
370
400
  target_node = params.target_node;
371
- render_in_place = typeof window === `undefined` || !document.body.contains(node);
401
+ render_in_place = typeof window === `undefined` ||
402
+ !document.body.contains(node);
372
403
  if (open && !render_in_place && target_node)
373
404
  tick().then(update_position);
374
405
  else if (!open || !target_node)
@@ -432,7 +463,11 @@ function portal(node, params) {
432
463
  {#if expandIcon}
433
464
  {@render expandIcon({ open })}
434
465
  {:else}
435
- <ExpandIcon width="15px" style="min-width: 1em; padding: 0 1pt; cursor: pointer;" />
466
+ <Icon
467
+ icon="ChevronExpand"
468
+ width="15px"
469
+ style="min-width: 1em; padding: 0 1pt; cursor: pointer"
470
+ />
436
471
  {/if}
437
472
  <ul
438
473
  class="selected {ulSelectedClass}"
@@ -441,7 +476,9 @@ function portal(node, params) {
441
476
  >
442
477
  {#each selected as option, idx (duplicates ? [key(option), idx] : key(option))}
443
478
  {@const selectedOptionStyle =
444
- [get_style(option, `selected`), liSelectedStyle].filter(Boolean).join(` `) ||
479
+ [get_style(option, `selected`), liSelectedStyle].filter(Boolean).join(
480
+ ` `,
481
+ ) ||
445
482
  null}
446
483
  <li
447
484
  class={liSelectedClass}
@@ -460,14 +497,14 @@ function portal(node, params) {
460
497
  >
461
498
  {#if selectedItem}
462
499
  {@render selectedItem({
463
- option,
464
- idx,
465
- })}
500
+ option,
501
+ idx,
502
+ })}
466
503
  {:else if children}
467
504
  {@render children({
468
- option,
469
- idx,
470
- })}
505
+ option,
506
+ idx,
507
+ })}
471
508
  {:else if parseLabelsAsHtml}
472
509
  {@html get_label(option)}
473
510
  {:else}
@@ -484,7 +521,7 @@ function portal(node, params) {
484
521
  {#if removeIcon}
485
522
  {@render removeIcon()}
486
523
  {:else}
487
- <CrossIcon width="15px" />
524
+ <Icon icon="Cross" width="15px" />
488
525
  {/if}
489
526
  </button>
490
527
  {/if}
@@ -507,7 +544,7 @@ function portal(node, params) {
507
544
  onkeydown={handle_input_keydown}
508
545
  onfocus={handle_input_focus}
509
546
  oninput={highlight_matching_options}
510
- {onblur}
547
+ onblur={handle_input_blur}
511
548
  {onclick}
512
549
  {onkeyup}
513
550
  {onmousedown}
@@ -519,16 +556,15 @@ function portal(node, params) {
519
556
  {ontouchstart}
520
557
  {...rest}
521
558
  />
522
- <!-- the above on:* lines forward potentially useful DOM events -->
523
559
  {@render afterInput?.({
524
- selected,
525
- disabled,
526
- invalid,
527
- id,
528
- placeholder,
529
- open,
530
- required,
531
- })}
560
+ selected,
561
+ disabled,
562
+ invalid,
563
+ id,
564
+ placeholder,
565
+ open,
566
+ required,
567
+ })}
532
568
  </ul>
533
569
  {#if loading}
534
570
  {#if spinner}
@@ -541,7 +577,12 @@ function portal(node, params) {
541
577
  {#if disabledIcon}
542
578
  {@render disabledIcon()}
543
579
  {:else}
544
- <DisabledIcon width="14pt" style="margin: 0 2pt;" data-name="disabled-icon" />
580
+ <Icon
581
+ icon="Disabled"
582
+ width="14pt"
583
+ style="margin: 0 2pt"
584
+ data-name="disabled-icon"
585
+ />
545
586
  {/if}
546
587
  {:else if selected.length > 0}
547
588
  {#if maxSelect && (maxSelect > 1 || maxSelectMsg)}
@@ -562,7 +603,7 @@ function portal(node, params) {
562
603
  {#if removeIcon}
563
604
  {@render removeIcon()}
564
605
  {:else}
565
- <CrossIcon width="15px" />
606
+ <Icon icon="Cross" width="15px" />
566
607
  {/if}
567
608
  </button>
568
609
  {/if}
@@ -581,25 +622,29 @@ function portal(node, params) {
581
622
  bind:this={ul_options}
582
623
  style={ulOptionsStyle}
583
624
  >
584
- {#each matchingOptions.slice(0, Math.max(0, maxOptions ?? 0) || Infinity) as optionItem, idx (duplicates ? [key(optionItem), idx] : key(optionItem))}
625
+ {#each matchingOptions.slice(0, Math.max(0, maxOptions ?? 0) || Infinity) as
626
+ optionItem,
627
+ idx
628
+ (duplicates ? [key(optionItem), idx] : key(optionItem))
629
+ }
585
630
  {@const {
586
- label,
587
- disabled = null,
588
- title = null,
589
- selectedTitle = null,
590
- disabledTitle = defaultDisabledTitle,
591
- } = optionItem instanceof Object ? optionItem : { label: optionItem }}
631
+ label,
632
+ disabled = null,
633
+ title = null,
634
+ selectedTitle = null,
635
+ disabledTitle = defaultDisabledTitle,
636
+ } = optionItem instanceof Object ? optionItem : { label: optionItem }}
592
637
  {@const active = activeIndex === idx}
593
638
  {@const optionStyle =
594
- [get_style(optionItem, `option`), liOptionStyle].filter(Boolean).join(` `) ||
595
- null}
639
+ [get_style(optionItem, `option`), liOptionStyle].filter(Boolean).join(
640
+ ` `,
641
+ ) ||
642
+ null}
596
643
  <li
597
644
  onclick={(event) => {
598
645
  if (!disabled) add(optionItem, event)
599
646
  }}
600
- title={disabled
601
- ? disabledTitle
602
- : (is_selected(label) && selectedTitle) || title}
647
+ title={disabled ? disabledTitle : (is_selected(label) && selectedTitle) || title}
603
648
  class:selected={is_selected(label)}
604
649
  class:active
605
650
  class:disabled
@@ -622,14 +667,14 @@ function portal(node, params) {
622
667
  >
623
668
  {#if option}
624
669
  {@render option({
625
- option: optionItem,
626
- idx,
627
- })}
670
+ option: optionItem,
671
+ idx,
672
+ })}
628
673
  {:else if children}
629
674
  {@render children({
630
- option: optionItem,
631
- idx,
632
- })}
675
+ option: optionItem,
676
+ idx,
677
+ })}
633
678
  {:else if parseLabelsAsHtml}
634
679
  {@html get_label(optionItem)}
635
680
  {:else}
@@ -641,15 +686,15 @@ function portal(node, params) {
641
686
  {@const text_input_is_duplicate = selected.map(get_label).includes(searchText)}
642
687
  {@const is_dupe = !duplicates && text_input_is_duplicate && `dupe`}
643
688
  {@const can_create = Boolean(allowUserOptions && createOptionMsg) && `create`}
644
- {@const no_match =
645
- Boolean(matchingOptions?.length == 0 && noMatchingOptionsMsg) && `no-match`}
689
+ {@const no_match = Boolean(matchingOptions?.length == 0 && noMatchingOptionsMsg) &&
690
+ `no-match`}
646
691
  {@const msgType = is_dupe || can_create || no_match}
647
692
  {#if msgType}
648
693
  {@const msg = {
649
- dupe: duplicateOptionMsg,
650
- create: createOptionMsg,
651
- 'no-match': noMatchingOptionsMsg,
652
- }[msgType]}
694
+ dupe: duplicateOptionMsg,
695
+ create: createOptionMsg,
696
+ 'no-match': noMatchingOptionsMsg,
697
+ }[msgType]}
653
698
  <li
654
699
  onclick={(event) => {
655
700
  if (msgType === `create` && allowUserOptions) {
@@ -667,10 +712,10 @@ function portal(node, params) {
667
712
  }
668
713
  }}
669
714
  title={msgType === `create`
670
- ? createOptionMsg
671
- : msgType === `dupe`
672
- ? duplicateOptionMsg
673
- : ``}
715
+ ? createOptionMsg
716
+ : msgType === `dupe`
717
+ ? duplicateOptionMsg
718
+ : ``}
674
719
  class:active={option_msg_is_active}
675
720
  onmouseover={() => (option_msg_is_active = true)}
676
721
  onfocus={() => (option_msg_is_active = true)}
@@ -678,9 +723,11 @@ function portal(node, params) {
678
723
  onblur={() => (option_msg_is_active = false)}
679
724
  role="option"
680
725
  aria-selected="false"
681
- class="user-msg {liUserMsgClass} {option_msg_is_active
726
+ class="
727
+ user-msg {liUserMsgClass} {option_msg_is_active
682
728
  ? liActiveUserMsgClass
683
- : ``}"
729
+ : ``}
730
+ "
684
731
  style:cursor={{
685
732
  dupe: `not-allowed`,
686
733
  create: `pointer`,
@@ -791,6 +838,12 @@ function portal(node, params) {
791
838
  cursor: inherit; /* needed for disabled state */
792
839
  border-radius: 0; /* reset ul.selected > li */
793
840
  }
841
+
842
+ /* When options are selected, placeholder is hidden in which case we minimize input width to avoid adding unnecessary width to div.multiselect */
843
+ :is(div.multiselect > ul.selected > input:not(:placeholder-shown)) {
844
+ min-width: 1px; /* Minimal width to remain interactive */
845
+ }
846
+
794
847
  /* don't wrap ::placeholder rules in :is() as it seems to be overpowered by browser defaults i.t.o. specificity */
795
848
  div.multiselect > ul.selected > input::placeholder {
796
849
  padding-left: 5pt;
@@ -820,7 +873,8 @@ function portal(node, params) {
820
873
  z-index: var(--sms-options-z-index, 3);
821
874
 
822
875
  overflow: auto;
823
- transition: all 0.2s; /* Consider if this transition is desirable with portal positioning */
876
+ transition: all
877
+ 0.2s; /* Consider if this transition is desirable with portal positioning */
824
878
  box-sizing: border-box;
825
879
  background: var(--sms-options-bg, white);
826
880
  max-height: var(--sms-options-max-height, 50vh);
@@ -1,10 +1,9 @@
1
- import type { MultiSelectProps } from './props';
2
- import type { Option as T } from './types';
1
+ import type { MultiSelectProps, Option as T } from './types';
3
2
  declare class __sveltets_Render<Option extends T> {
4
3
  props(): MultiSelectProps<T>;
5
4
  events(): {};
6
5
  slots(): {};
7
- bindings(): "value" | "selected" | "invalid" | "open" | "activeIndex" | "activeOption" | "form_input" | "input" | "matchingOptions" | "options" | "outerDiv" | "searchText";
6
+ bindings(): "open" | "input" | "value" | "selected" | "invalid" | "activeIndex" | "activeOption" | "form_input" | "matchingOptions" | "options" | "outerDiv" | "searchText";
8
7
  exports(): {};
9
8
  }
10
9
  interface $$IsomorphicComponent {
@@ -0,0 +1,100 @@
1
+ <script lang="ts">let { items = [], node = `nav`, current = ``, log = `errors`, nav_options = { replace_state: true, no_scroll: true }, titles = { prev: `&larr; Previous`, next: `Next &rarr;` }, onkeyup = ({ prev, next }) => ({
2
+ ArrowLeft: prev[0],
3
+ ArrowRight: next[0],
4
+ }), prev_snippet, children, between, next_snippet, ...rest } = $props();
5
+ // Convert items to consistent [key, value] format
6
+ let items_arr = $derived((items ?? []).map((itm) => (typeof itm === `string` ? [itm, itm] : itm)));
7
+ // Calculate prev/next items with wraparound
8
+ let idx = $derived(items_arr.findIndex(([key]) => key === current));
9
+ let prev = $derived(items_arr[idx - 1] ?? items_arr[items_arr.length - 1]);
10
+ let next = $derived(items_arr[idx + 1] ?? items_arr[0]);
11
+ // Validation and logging
12
+ $effect.pre(() => {
13
+ if (log !== `silent`) {
14
+ if (items_arr.length < 2 && log === `verbose`) {
15
+ console.warn(`PrevNext received ${items_arr.length} items - minimum of 2 expected`);
16
+ }
17
+ if (idx < 0 && log === `errors`) {
18
+ const valid = items_arr.map(([key]) => key);
19
+ console.error(`PrevNext received invalid current=${current}, expected one of ${valid}`);
20
+ }
21
+ }
22
+ });
23
+ function handle_keyup(event) {
24
+ if (!onkeyup)
25
+ return;
26
+ const key_map = onkeyup({ prev, next });
27
+ const to = key_map[event.key];
28
+ if (to) {
29
+ const { replace_state, no_scroll } = nav_options;
30
+ const [scroll_x, scroll_y] = no_scroll
31
+ ? [window.scrollX, window.scrollY]
32
+ : [0, 0];
33
+ const goto = window.history[replace_state ? `replaceState` : `pushState`];
34
+ goto.call(window.history, {}, ``, to); // Navigate using appropriate history method
35
+ if (no_scroll)
36
+ window.scrollTo(scroll_x, scroll_y); // Restore scroll position if needed
37
+ }
38
+ }
39
+ export {};
40
+ </script>
41
+
42
+ <svelte:window onkeyup={handle_keyup} />
43
+
44
+ {#if items_arr.length > 2}
45
+ <svelte:element this={node} class="prev-next" {...rest}>
46
+ <!-- ensures `prev` is a defined [key, value] tuple.
47
+ Due to prior normalization of the `items` prop, any defined `prev` item
48
+ is guaranteed to be a 2-element array except if `prev` is null.
49
+ -->
50
+ {#if prev?.length >= 2}
51
+ {#if prev_snippet}
52
+ {@render prev_snippet({ item: prev })}
53
+ {:else if children}
54
+ {@render children({ kind: `prev`, item: prev })}
55
+ {:else}
56
+ <div>
57
+ {#if titles.prev}<span>{@html titles.prev}</span>{/if}
58
+ <a href={prev[0]}>{prev[0]}</a>
59
+ </div>
60
+ {/if}
61
+ {/if}
62
+ {@render between?.()}
63
+ {#if next?.length >= 2}
64
+ {#if next_snippet}
65
+ {@render next_snippet({ item: next })}
66
+ {:else if children}
67
+ {@render children({ kind: `next`, item: next })}
68
+ {:else}
69
+ <div>
70
+ {#if titles.next}<span>{@html titles.next}</span>{/if}
71
+ <a href={next[0]}>{next[0]}</a>
72
+ </div>
73
+ {/if}
74
+ {/if}
75
+ </svelte:element>
76
+ {/if}
77
+
78
+ <style>
79
+ .prev-next {
80
+ display: flex;
81
+ list-style: none;
82
+ place-content: space-between;
83
+ gap: var(--prev-next-gap, 2em);
84
+ padding: var(--prev-next-padding, 0);
85
+ margin: var(--prev-next-margin, 3em auto);
86
+ }
87
+ .prev-next a {
88
+ color: var(--prev-next-color);
89
+ background: var(--prev-next-link-bg);
90
+ padding: var(--prev-next-link-padding);
91
+ border-radius: var(--prev-next-link-border-radius);
92
+ }
93
+ .prev-next span {
94
+ display: block;
95
+ margin: var(--prev-next-label-margin, 0 auto 1ex);
96
+ }
97
+ .prev-next > div:nth-child(2) {
98
+ text-align: right;
99
+ }
100
+ </style>
@@ -0,0 +1,48 @@
1
+ import type { Snippet } from 'svelte';
2
+ export type Item = string | [string, unknown];
3
+ declare class __sveltets_Render<T extends Item> {
4
+ props(): {
5
+ [key: string]: unknown;
6
+ items?: T[] | undefined;
7
+ node?: string;
8
+ current?: string;
9
+ log?: `verbose` | `errors` | `silent`;
10
+ nav_options?: {
11
+ replace_state: boolean;
12
+ no_scroll: boolean;
13
+ } | undefined;
14
+ titles?: {
15
+ prev: string;
16
+ next: string;
17
+ } | undefined;
18
+ onkeyup?: ((obj: {
19
+ prev: Item;
20
+ next: Item;
21
+ }) => Record<string, string>) | null | undefined;
22
+ prev_snippet?: Snippet<[{
23
+ item: Item;
24
+ }]> | undefined;
25
+ children?: Snippet<[{
26
+ kind: `prev` | `next`;
27
+ item: Item;
28
+ }]> | undefined;
29
+ between?: Snippet<[]>;
30
+ next_snippet?: Snippet<[{
31
+ item: Item;
32
+ }]> | undefined;
33
+ };
34
+ events(): {};
35
+ slots(): {};
36
+ bindings(): "";
37
+ exports(): {};
38
+ }
39
+ interface $$IsomorphicComponent {
40
+ new <T extends Item>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
41
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
42
+ } & ReturnType<__sveltets_Render<T>['exports']>;
43
+ <T extends Item>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
44
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
45
+ }
46
+ declare const PrevNext: $$IsomorphicComponent;
47
+ type PrevNext<T extends Item> = InstanceType<typeof PrevNext<T>>;
48
+ export default PrevNext;
@@ -0,0 +1,67 @@
1
+ <script lang="ts">// get the label key from an option object or the option itself if it's a string or number
2
+ const get_label = (op) => {
3
+ if (op instanceof Object) {
4
+ if (op.label === undefined) {
5
+ console.error(`RadioButton option ${JSON.stringify(op)} is an object but has no label key`);
6
+ }
7
+ return op.label;
8
+ }
9
+ return op;
10
+ };
11
+ let { options, selected = $bindable(), id = null, name = null, disabled = false, required = false, aria_label = null, onclick, onchange, oninput, option_snippet, children, ...rest } = $props();
12
+ export {};
13
+ </script>
14
+
15
+ <div {id} {...rest}>
16
+ {#each options as option (JSON.stringify(option))}
17
+ {@const label = get_label(option)}
18
+ {@const active = selected && get_label(option) === get_label(selected)}
19
+ <label class:active aria-label={aria_label}>
20
+ <input
21
+ type="radio"
22
+ value={option}
23
+ {name}
24
+ {disabled}
25
+ {required}
26
+ bind:group={selected}
27
+ {onchange}
28
+ {oninput}
29
+ {onclick}
30
+ />
31
+ {#if option_snippet}
32
+ {@render option_snippet({ option, selected, active })}
33
+ {:else if children}
34
+ {@render children({ option, selected, active })}
35
+ {:else}<span>{label}</span>{/if}
36
+ </label>
37
+ {/each}
38
+ </div>
39
+
40
+ <style>
41
+ div {
42
+ max-width: max-content;
43
+ overflow: hidden;
44
+ height: fit-content;
45
+ display: var(--radio-btn-display, inline-flex);
46
+ border-radius: var(--radio-btn-border-radius, 0.5em);
47
+ }
48
+ input {
49
+ display: none;
50
+ }
51
+ span {
52
+ cursor: pointer;
53
+ display: inline-block;
54
+ color: var(--radio-btn-color, white);
55
+ padding: var(--radio-btn-padding, 2pt 5pt);
56
+ background: var(--radio-btn-bg, black);
57
+ transition: var(--radio-btn-transition, background 0.3s, transform 0.3s);
58
+ }
59
+ label:not(.active) span:hover {
60
+ background: var(--radio-btn-hover-bg, cornflowerblue);
61
+ color: var(--radio-btn-hover-color, white);
62
+ }
63
+ label.active span {
64
+ box-shadow: var(--radio-btn-checked-shadow, inset 0 0 1em -3pt black);
65
+ background: var(--radio-btn-checked-bg, darkcyan);
66
+ }
67
+ </style>