svelte-multiselect 11.1.0 → 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 +128 -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 +18 -37
  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,15 +1,13 @@
1
- <script lang="ts">import { browser } from '$app/environment';
1
+ <script lang="ts">import { Icon, Wiggle } from './';
2
2
  import { tick } from 'svelte';
3
3
  import { flip } from 'svelte/animate';
4
4
  import CircleSpinner from './CircleSpinner.svelte';
5
- import Wiggle from './Wiggle.svelte';
6
- import { CrossIcon, DisabledIcon, ExpandIcon } from './icons';
7
5
  import { get_label, get_style, highlight_matching_nodes } from './utils';
8
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) => {
9
7
  if (!searchText)
10
8
  return true;
11
9
  return `${get_label(opt)}`.toLowerCase().includes(searchText.toLowerCase());
12
- }, 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
13
11
  ?.filter((opt) => opt instanceof Object && opt?.preselected)
14
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();
15
13
  $effect.pre(() => {
@@ -106,8 +104,9 @@ function add(option_to_add, event) {
106
104
  else {
107
105
  option_to_add = searchText; // else create custom option as string
108
106
  }
109
- oncreate?.({ option: option_to_add });
110
107
  }
108
+ // Fire oncreate event for all user-created options, regardless of type
109
+ oncreate?.({ option: option_to_add });
111
110
  if (allowUserOptions === `append`)
112
111
  options = [...options, option_to_add];
113
112
  }
@@ -136,9 +135,12 @@ function add(option_to_add, event) {
136
135
  }
137
136
  const reached_max_select = selected.length === maxSelect;
138
137
  const dropdown_should_close = closeDropdownOnSelect === true ||
139
- (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`;
140
142
  if (reached_max_select || dropdown_should_close) {
141
- close_dropdown(event);
143
+ close_dropdown(event, should_retain_focus);
142
144
  }
143
145
  else if (!dropdown_should_close) {
144
146
  input?.focus();
@@ -183,9 +185,10 @@ function open_dropdown(event) {
183
185
  }
184
186
  onopen?.({ event });
185
187
  }
186
- function close_dropdown(event) {
188
+ function close_dropdown(event, retain_focus = false) {
187
189
  open = false;
188
- input?.blur();
190
+ if (!retain_focus)
191
+ input?.blur();
189
192
  activeIndex = null;
190
193
  onclose?.({ event });
191
194
  }
@@ -196,8 +199,7 @@ async function handle_keydown(event) {
196
199
  event.stopPropagation();
197
200
  close_dropdown(event);
198
201
  searchText = ``;
199
- }
200
- // on enter key: toggle active option and reset search text
202
+ } // on enter key: toggle active option and reset search text
201
203
  else if (event.key === `Enter`) {
202
204
  event.stopPropagation();
203
205
  event.preventDefault(); // prevent enter key from triggering form submission
@@ -217,8 +219,7 @@ async function handle_keydown(event) {
217
219
  // in which case enter means open it
218
220
  open_dropdown(event);
219
221
  }
220
- }
221
- // on up/down arrow keys: update active option
222
+ } // on up/down arrow keys: update active option
222
223
  else if ([`ArrowDown`, `ArrowUp`].includes(event.key)) {
223
224
  event.stopPropagation();
224
225
  // if no option is active yet, but there are matching options, make first one active
@@ -253,14 +254,12 @@ async function handle_keydown(event) {
253
254
  if (li)
254
255
  li.scrollIntoViewIfNeeded?.();
255
256
  }
256
- }
257
- // on backspace key: remove last selected option
257
+ } // on backspace key: remove last selected option
258
258
  else if (event.key === `Backspace` && selected.length > 0 && !searchText) {
259
259
  event.stopPropagation();
260
260
  // Don't prevent default, allow normal backspace behavior if not removing
261
261
  remove(selected.at(-1), event);
262
- }
263
- // 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)
264
263
  else if (matchingOptions.length > 0 && activeIndex === null) {
265
264
  // Don't stop propagation or prevent default here, allow normal character input
266
265
  activeIndex = 0;
@@ -268,10 +267,11 @@ async function handle_keydown(event) {
268
267
  }
269
268
  function remove_all(event) {
270
269
  event.stopPropagation();
270
+ selected = []; // Set selected first
271
+ searchText = ``;
272
+ // Now trigger change events
271
273
  onremoveAll?.({ options: selected });
272
274
  onchange?.({ options: selected, type: `removeAll` });
273
- selected = [];
274
- searchText = ``;
275
275
  }
276
276
  let is_selected = $derived((label) => selected.map(get_label).includes(label));
277
277
  const if_enter_or_space = (handler) => (event) => {
@@ -329,10 +329,38 @@ const handle_input_keydown = (event) => {
329
329
  onkeydown?.(event);
330
330
  };
331
331
  const handle_input_focus = (event) => {
332
- open_dropdown(event); // Internal logic
333
- // Call original forwarded handler
332
+ open_dropdown(event);
334
333
  onfocus?.(event);
335
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
+ };
336
364
  // reset form validation when required prop changes
337
365
  // https://github.com/janosh/svelte-multiselect/issues/285
338
366
  $effect.pre(() => {
@@ -343,7 +371,8 @@ function portal(node, params) {
343
371
  let { target_node, active } = params;
344
372
  if (!active)
345
373
  return;
346
- let render_in_place = !browser || !document.body.contains(node);
374
+ let render_in_place = typeof window === `undefined` ||
375
+ !document.body.contains(node);
347
376
  if (!render_in_place) {
348
377
  document.body.appendChild(node);
349
378
  node.style.position = `fixed`;
@@ -369,7 +398,8 @@ function portal(node, params) {
369
398
  return {
370
399
  update(params) {
371
400
  target_node = params.target_node;
372
- render_in_place = !browser || !document.body.contains(node);
401
+ render_in_place = typeof window === `undefined` ||
402
+ !document.body.contains(node);
373
403
  if (open && !render_in_place && target_node)
374
404
  tick().then(update_position);
375
405
  else if (!open || !target_node)
@@ -433,7 +463,11 @@ function portal(node, params) {
433
463
  {#if expandIcon}
434
464
  {@render expandIcon({ open })}
435
465
  {:else}
436
- <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
+ />
437
471
  {/if}
438
472
  <ul
439
473
  class="selected {ulSelectedClass}"
@@ -442,7 +476,9 @@ function portal(node, params) {
442
476
  >
443
477
  {#each selected as option, idx (duplicates ? [key(option), idx] : key(option))}
444
478
  {@const selectedOptionStyle =
445
- [get_style(option, `selected`), liSelectedStyle].filter(Boolean).join(` `) ||
479
+ [get_style(option, `selected`), liSelectedStyle].filter(Boolean).join(
480
+ ` `,
481
+ ) ||
446
482
  null}
447
483
  <li
448
484
  class={liSelectedClass}
@@ -461,14 +497,14 @@ function portal(node, params) {
461
497
  >
462
498
  {#if selectedItem}
463
499
  {@render selectedItem({
464
- option,
465
- idx,
466
- })}
500
+ option,
501
+ idx,
502
+ })}
467
503
  {:else if children}
468
504
  {@render children({
469
- option,
470
- idx,
471
- })}
505
+ option,
506
+ idx,
507
+ })}
472
508
  {:else if parseLabelsAsHtml}
473
509
  {@html get_label(option)}
474
510
  {:else}
@@ -485,7 +521,7 @@ function portal(node, params) {
485
521
  {#if removeIcon}
486
522
  {@render removeIcon()}
487
523
  {:else}
488
- <CrossIcon width="15px" />
524
+ <Icon icon="Cross" width="15px" />
489
525
  {/if}
490
526
  </button>
491
527
  {/if}
@@ -508,7 +544,7 @@ function portal(node, params) {
508
544
  onkeydown={handle_input_keydown}
509
545
  onfocus={handle_input_focus}
510
546
  oninput={highlight_matching_options}
511
- {onblur}
547
+ onblur={handle_input_blur}
512
548
  {onclick}
513
549
  {onkeyup}
514
550
  {onmousedown}
@@ -520,16 +556,15 @@ function portal(node, params) {
520
556
  {ontouchstart}
521
557
  {...rest}
522
558
  />
523
- <!-- the above on:* lines forward potentially useful DOM events -->
524
559
  {@render afterInput?.({
525
- selected,
526
- disabled,
527
- invalid,
528
- id,
529
- placeholder,
530
- open,
531
- required,
532
- })}
560
+ selected,
561
+ disabled,
562
+ invalid,
563
+ id,
564
+ placeholder,
565
+ open,
566
+ required,
567
+ })}
533
568
  </ul>
534
569
  {#if loading}
535
570
  {#if spinner}
@@ -542,7 +577,12 @@ function portal(node, params) {
542
577
  {#if disabledIcon}
543
578
  {@render disabledIcon()}
544
579
  {:else}
545
- <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
+ />
546
586
  {/if}
547
587
  {:else if selected.length > 0}
548
588
  {#if maxSelect && (maxSelect > 1 || maxSelectMsg)}
@@ -563,7 +603,7 @@ function portal(node, params) {
563
603
  {#if removeIcon}
564
604
  {@render removeIcon()}
565
605
  {:else}
566
- <CrossIcon width="15px" />
606
+ <Icon icon="Cross" width="15px" />
567
607
  {/if}
568
608
  </button>
569
609
  {/if}
@@ -582,25 +622,29 @@ function portal(node, params) {
582
622
  bind:this={ul_options}
583
623
  style={ulOptionsStyle}
584
624
  >
585
- {#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
+ }
586
630
  {@const {
587
- label,
588
- disabled = null,
589
- title = null,
590
- selectedTitle = null,
591
- disabledTitle = defaultDisabledTitle,
592
- } = 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 }}
593
637
  {@const active = activeIndex === idx}
594
638
  {@const optionStyle =
595
- [get_style(optionItem, `option`), liOptionStyle].filter(Boolean).join(` `) ||
596
- null}
639
+ [get_style(optionItem, `option`), liOptionStyle].filter(Boolean).join(
640
+ ` `,
641
+ ) ||
642
+ null}
597
643
  <li
598
644
  onclick={(event) => {
599
645
  if (!disabled) add(optionItem, event)
600
646
  }}
601
- title={disabled
602
- ? disabledTitle
603
- : (is_selected(label) && selectedTitle) || title}
647
+ title={disabled ? disabledTitle : (is_selected(label) && selectedTitle) || title}
604
648
  class:selected={is_selected(label)}
605
649
  class:active
606
650
  class:disabled
@@ -623,14 +667,14 @@ function portal(node, params) {
623
667
  >
624
668
  {#if option}
625
669
  {@render option({
626
- option: optionItem,
627
- idx,
628
- })}
670
+ option: optionItem,
671
+ idx,
672
+ })}
629
673
  {:else if children}
630
674
  {@render children({
631
- option: optionItem,
632
- idx,
633
- })}
675
+ option: optionItem,
676
+ idx,
677
+ })}
634
678
  {:else if parseLabelsAsHtml}
635
679
  {@html get_label(optionItem)}
636
680
  {:else}
@@ -642,15 +686,15 @@ function portal(node, params) {
642
686
  {@const text_input_is_duplicate = selected.map(get_label).includes(searchText)}
643
687
  {@const is_dupe = !duplicates && text_input_is_duplicate && `dupe`}
644
688
  {@const can_create = Boolean(allowUserOptions && createOptionMsg) && `create`}
645
- {@const no_match =
646
- Boolean(matchingOptions?.length == 0 && noMatchingOptionsMsg) && `no-match`}
689
+ {@const no_match = Boolean(matchingOptions?.length == 0 && noMatchingOptionsMsg) &&
690
+ `no-match`}
647
691
  {@const msgType = is_dupe || can_create || no_match}
648
692
  {#if msgType}
649
693
  {@const msg = {
650
- dupe: duplicateOptionMsg,
651
- create: createOptionMsg,
652
- 'no-match': noMatchingOptionsMsg,
653
- }[msgType]}
694
+ dupe: duplicateOptionMsg,
695
+ create: createOptionMsg,
696
+ 'no-match': noMatchingOptionsMsg,
697
+ }[msgType]}
654
698
  <li
655
699
  onclick={(event) => {
656
700
  if (msgType === `create` && allowUserOptions) {
@@ -668,10 +712,10 @@ function portal(node, params) {
668
712
  }
669
713
  }}
670
714
  title={msgType === `create`
671
- ? createOptionMsg
672
- : msgType === `dupe`
673
- ? duplicateOptionMsg
674
- : ``}
715
+ ? createOptionMsg
716
+ : msgType === `dupe`
717
+ ? duplicateOptionMsg
718
+ : ``}
675
719
  class:active={option_msg_is_active}
676
720
  onmouseover={() => (option_msg_is_active = true)}
677
721
  onfocus={() => (option_msg_is_active = true)}
@@ -679,9 +723,11 @@ function portal(node, params) {
679
723
  onblur={() => (option_msg_is_active = false)}
680
724
  role="option"
681
725
  aria-selected="false"
682
- class="user-msg {liUserMsgClass} {option_msg_is_active
726
+ class="
727
+ user-msg {liUserMsgClass} {option_msg_is_active
683
728
  ? liActiveUserMsgClass
684
- : ``}"
729
+ : ``}
730
+ "
685
731
  style:cursor={{
686
732
  dupe: `not-allowed`,
687
733
  create: `pointer`,
@@ -792,6 +838,12 @@ function portal(node, params) {
792
838
  cursor: inherit; /* needed for disabled state */
793
839
  border-radius: 0; /* reset ul.selected > li */
794
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
+
795
847
  /* don't wrap ::placeholder rules in :is() as it seems to be overpowered by browser defaults i.t.o. specificity */
796
848
  div.multiselect > ul.selected > input::placeholder {
797
849
  padding-left: 5pt;
@@ -821,7 +873,8 @@ function portal(node, params) {
821
873
  z-index: var(--sms-options-z-index, 3);
822
874
 
823
875
  overflow: auto;
824
- 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 */
825
878
  box-sizing: border-box;
826
879
  background: var(--sms-options-bg, white);
827
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>