@txstate-mws/svelte-components 1.5.13 → 1.6.1

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.
@@ -78,6 +78,7 @@ export function glue(el, { target, align = 'auto', cover = false, gap = 0, adjus
78
78
  }
79
79
  store?.update(v => ({ ...v, valign, halign }));
80
80
  }
81
+ // eslint-disable-next-line @typescript-eslint/unbound-method
81
82
  const { destroy: watchDestroy } = watchForMutations(reposition);
82
83
  document.addEventListener('scroll', reposition, { capture: true });
83
84
  return {
@@ -13,7 +13,7 @@ When they do, the escape event will be fired, allowing you to remove the dialog.
13
13
  When it goes away, they'll be trapped inside the previous `FocusLock`, and so on.
14
14
  -->
15
15
  <script context="module">export const FocusLockStack = [];
16
- const waitAtick = typeof requestAnimationFrame !== 'undefined' ? resolve => requestAnimationFrame(resolve) : resolve => resolve(0);
16
+ const waitAtick = typeof requestAnimationFrame !== 'undefined' ? (resolve) => requestAnimationFrame(resolve) : resolve => resolve(0);
17
17
  </script>
18
18
 
19
19
  <script>import { onMount, onDestroy, createEventDispatcher } from 'svelte';
@@ -53,7 +53,7 @@ onMount(async () => {
53
53
  for (const entry of FocusLockStack)
54
54
  entry.update();
55
55
  if (typeof returnfocusto === 'undefined') {
56
- returnfocusto = document.querySelector(':focus');
56
+ returnfocusto = document.activeElement;
57
57
  }
58
58
  await sleep(1); // sapper resets focus on page load, so we'll wait a millisecond in case our modal is on screen at load
59
59
  if (!lockelement)
@@ -11,7 +11,7 @@ declare const __propDef: {
11
11
  hidefocus?: boolean | undefined;
12
12
  hidefocuslabel?: string | undefined;
13
13
  initialfocus?: string | undefined;
14
- returnfocusto?: HTMLElement | null | undefined;
14
+ returnfocusto?: HTMLElement | undefined;
15
15
  /** If you expect any popup menus to be added to the body, we need to know that they
16
16
  are considered to be part of the focus lock, or else the modal will be dismissed
17
17
  when the user clicks inside. Use commas to include multiple selectors. */ includeselector?: string | undefined;
@@ -7,7 +7,7 @@
7
7
  debounced callback on the contents of the text input.
8
8
  -->
9
9
  <script>import { createEventDispatcher, onMount } from 'svelte';
10
- import { randomid, Cache } from 'txstate-utils';
10
+ import { randomid, Cache, isNotBlank } from 'txstate-utils';
11
11
  import ScreenReaderOnly from './ScreenReaderOnly.svelte';
12
12
  import DefaultPopupMenu from './PopupMenu.svelte';
13
13
  import { modifierKey, selectionIsLeft } from '../util';
@@ -45,9 +45,11 @@ let menushown;
45
45
  let loading = false;
46
46
  let options = [];
47
47
  let hilitedpill;
48
- let inputvalue = '';
49
48
  let popupvalue = undefined;
50
49
  let inputelement;
50
+ let menuid;
51
+ let announcement = '';
52
+ const instructionid = randomid();
51
53
  const dispatch = createEventDispatcher();
52
54
  const optionsCache = new Cache(async (ipt) => {
53
55
  return await getOptions(ipt);
@@ -63,23 +65,23 @@ async function reactToInput(..._) {
63
65
  loading = true;
64
66
  clearTimeout(optionsTimer);
65
67
  setTimeout(async () => {
66
- const saveval = inputvalue;
68
+ const saveval = inputelement?.value;
67
69
  const rawOptions = await optionsCache.get(saveval);
68
- if (inputvalue !== saveval)
70
+ if (inputelement?.value !== saveval)
69
71
  return; // ignore any results that are out of date
70
72
  options = rawOptions.filter(o => !('value' in o) || !selectedSet.has(o.value));
71
- if (typeof document !== 'undefined' && inputelement === document.activeElement && (options.length || inputvalue?.length))
73
+ if (typeof document !== 'undefined' && inputelement === document.activeElement && (options.length || inputelement?.value.length) && !menushown)
72
74
  menushown = true;
73
75
  loading = false;
74
76
  }, 250);
75
77
  }
76
- $: void reactToInput(inputvalue, getOptions, selectedSet);
77
- $: availablemessage = options.filter(o => 'value' in o && o.value).length + ' autocomplete choices available';
78
+ $: void reactToInput(getOptions, selectedSet);
78
79
  function addSelection(e) {
79
- inputvalue = '';
80
+ inputelement.value = '';
80
81
  const opt = { ...e.detail, label: e.detail.label || e.detail.value };
81
82
  selected = maxSelections === 1 ? [opt] : [...selected, opt];
82
83
  popupvalue = undefined;
84
+ announcement = `${maxSelections === 1 ? 'selected' : 'added'} ${opt.value}`;
83
85
  dispatch('change', selected);
84
86
  }
85
87
  function removeSelection(opt, idx, nextfocus = 1) {
@@ -87,6 +89,9 @@ function removeSelection(opt, idx, nextfocus = 1) {
87
89
  hilitedpill = selected[idx + nextfocus]?.value;
88
90
  }
89
91
  selected = selected.filter(s => s.value !== opt.value);
92
+ if (document.activeElement !== inputelement)
93
+ inputelement.focus();
94
+ announcement = `removed ${opt.value}`;
90
95
  dispatch('change', selected);
91
96
  }
92
97
  function inputkeydown(e) {
@@ -114,17 +119,23 @@ function inputkeydown(e) {
114
119
  removeSelection(selected[idx], idx, e.key === 'Delete' ? 1 : -1);
115
120
  e.preventDefault();
116
121
  }
117
- else if (e.key === 'Backspace' && selectionIsLeft(inputelement)) {
118
- removeSelection(selected[selected.length - 1], selected.length - 1, -1);
119
- e.preventDefault();
120
- }
121
122
  }
122
123
  else if (e.key !== 'Tab' && maxSelections > 1 && selected.length >= maxSelections) {
123
124
  e.preventDefault();
124
125
  }
126
+ else if (e.key === 'ArrowDown') {
127
+ hilitedpill = undefined;
128
+ }
125
129
  }
130
+ let active = false;
126
131
  function inputfocus() {
127
132
  void reactToInput();
133
+ active = true;
134
+ dispatch('focus');
135
+ }
136
+ function inputblur() {
137
+ active = false;
138
+ dispatch('blur');
128
139
  }
129
140
  let popuphilited;
130
141
  $: if (popuphilited != null)
@@ -140,6 +151,7 @@ function reactToHilite(..._) {
140
151
  inputelement.removeAttribute('aria-activedescendant');
141
152
  }
142
153
  $: reactToHilite(hilitedpill, id);
154
+ $: ariainstructions = `${selected.length === 0 ? 'nothing' : selected.length} selected, ${maxSelections ? 'select up to ' + maxSelections + ',' : 'select multiple,'} up down to browse choices, ${selected.length ? 'left right to browse selected,' : ''} touch users must explore screen to find menu`;
143
155
  let gap = 0;
144
156
  onMount(() => {
145
157
  const ul = inputelement.closest('.multiselect-selected');
@@ -148,72 +160,64 @@ onMount(() => {
148
160
  });
149
161
  </script>
150
162
 
151
- <fieldset>
152
- <ul class="multiselect-selected" class:disabled role="listbox">
153
- {#each selected as option, i}
154
- <!-- svelte-ignore a11y-click-events-have-key-events -->
155
- <li id={id + option.value} role="option" tabindex="-1" class="multiselect-pill" class:hilited={hilitedpill === option.value}
156
- on:click|preventDefault|stopPropagation={() => { !disabled && removeSelection(option, i, 1) }} on:mousedown={e => { disabled && e.preventDefault() }}
157
- aria-selected="true">
158
- {option.label || option.value}
159
- <i aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256"><path fill="currentColor" d="M205.66 194.34a8 8 0 0 1-11.32 11.32L128 139.31l-66.34 66.35a8 8 0 0 1-11.32-11.32L116.69 128L50.34 61.66a8 8 0 0 1 11.32-11.32L128 116.69l66.34-66.35a8 8 0 0 1 11.32 11.32L139.31 128Z"/></svg></i>
160
- <ScreenReaderOnly>, click to deselect</ScreenReaderOnly>
161
- </li>
162
- {/each}
163
- <li class={`input ${inputClass}`}>
164
- <input type="text" {id} {name} {disabled} {placeholder}
165
- bind:this={inputelement} bind:value={inputvalue} on:blur
166
- on:focus={inputfocus} on:keydown={inputkeydown}
167
- autocomplete="off" autocorrect="off" spellcheck="false" aria-autocomplete="list"
168
- aria-describedby={descid}>
169
- </li>
170
- </ul>
171
- <ScreenReaderOnly arialive="assertive">
172
- <span>{selected.length ? selected.length + ' selected' : 'none selected'}, select {maxSelections ? 'up to ' + maxSelections : 'multiple'}, up down to choose, left right to hilite existing choices</span>
173
- {#if menushown}<span>{availablemessage}, touch users explore to find autocomplete menu</span>{/if}
174
- </ScreenReaderOnly>
163
+ <div class="multiselect" class:disabled role="combobox" aria-controls={menushown ? menuid : undefined} aria-expanded={menushown}>
164
+ {#if selected.length}
165
+ <div class="multiselect-selected" role="listbox" aria-label="selected options">
166
+ {#each selected as option, i (option.value)}
167
+ <!-- svelte-ignore a11y-click-events-have-key-events -->
168
+ <div id={id + option.value} role="option" tabindex="-1" class="multiselect-pill" class:hilited={hilitedpill === option.value}
169
+ on:click|preventDefault|stopPropagation={() => { !disabled && removeSelection(option, i, 1) }} on:mousedown={e => { disabled && e.preventDefault() }}
170
+ aria-selected="true" aria-posinset={i + 1} aria-setsize={selected.length}>
171
+ {option.label || option.value}
172
+ <i aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256"><path fill="currentColor" d="M205.66 194.34a8 8 0 0 1-11.32 11.32L128 139.31l-66.34 66.35a8 8 0 0 1-11.32-11.32L116.69 128L50.34 61.66a8 8 0 0 1 11.32-11.32L128 116.69l66.34-66.35a8 8 0 0 1 11.32 11.32L139.31 128Z"/></svg></i>
173
+ <ScreenReaderOnly>, click to deselect</ScreenReaderOnly>
174
+ </div>
175
+ {/each}
176
+ </div>
177
+ {/if}
178
+ <!-- svelte-ignore a11y-role-has-required-aria-props -->
179
+ <input type="text" class={inputClass} {id} {name} {disabled} {placeholder}
180
+ bind:this={inputelement} on:input={reactToInput} on:blur={inputblur}
181
+ on:focus={inputfocus} on:keydown={inputkeydown}
182
+ autocomplete="off" autocorrect="off" spellcheck="false"
183
+ aria-describedby="{[descid, instructionid].filter(isNotBlank).join(' ')}">
175
184
  <slot></slot>
176
- </fieldset>
177
- <svelte:component this={PopupMenu} bind:menushown bind:hilited={popuphilited} bind:value={popupvalue} align='bottomleft'
185
+ <ScreenReaderOnly id={instructionid}>{ariainstructions}</ScreenReaderOnly>
186
+ <ScreenReaderOnly arialive="polite">{announcement}</ScreenReaderOnly>
187
+ </div>
188
+
189
+ <svelte:component this={PopupMenu} bind:menushown bind:menuid bind:hilited={popuphilited} bind:value={popupvalue} align='bottomleft'
178
190
  {usePortal} {loading} {emptyText} {gap}
179
191
  {menuContainerClass} {menuClass} {menuItemClass} {menuItemHilitedClass} {menuDividerClass}
180
192
  items={options} buttonelement={inputelement}
181
193
  on:change={addSelection}/>
182
194
 
183
195
  <style>
184
- fieldset {
185
- border: 0;
186
- padding: 0;
187
- margin: 0;
188
- }
189
- fieldset * {
196
+ .multiselect, .multiselect * {
190
197
  box-sizing: border-box;
191
198
  }
192
- .multiselect-selected {
193
- margin: 0;
194
- padding: 0;
195
- list-style: none;
199
+ .multiselect {
196
200
  display: flex;
197
201
  flex-wrap: wrap;
198
- align-items: stretch;
199
202
  padding: var(--multiselect-padding, 0.3em);
200
203
  border: var(--multiselect-border, 1px solid #666666);
201
204
  border-radius: var(--multiselect-radius, 0.3em);
202
205
  }
203
- .multiselect-selected:focus-within {
206
+ .multiselect:focus-within {
204
207
  outline-width: var(--multiselect-focus-width, 2px);
205
208
  outline-style: solid;
206
209
  outline-color: var(--multiselect-focus-color, Highlight);
207
210
  }
208
- li.input {
209
- flex-grow: 1;
211
+ .multiselect-selected {
212
+ display: flex;
213
+ flex-wrap: wrap;
214
+ align-items: center;
210
215
  }
211
216
  input {
217
+ flex-grow: 1;
212
218
  outline: 0;
213
219
  border: 0;
214
220
  min-width: 0;
215
- width: 100%;
216
- height: 100%;
217
221
  }
218
222
  .multiselect-pill {
219
223
  position: relative;
@@ -244,7 +248,7 @@ onMount(() => {
244
248
  .multiselect-selected.disabled {
245
249
  opacity: 0.5;
246
250
  }
247
- .multiselect-selected.disabled .multiselect-pill {
251
+ .multiselect.disabled .multiselect-pill {
248
252
  cursor: default;
249
253
  }
250
254
  .multiselect-pill.hilited {
@@ -28,8 +28,9 @@ declare const __propDef: {
28
28
  /** You can define your own PopupMenu and pass for that to be used or accept DefaultPopupMenu. */ PopupMenu?: typeof DefaultPopupMenu | undefined;
29
29
  };
30
30
  events: {
31
- blur: FocusEvent;
32
31
  change: CustomEvent<any>;
32
+ focus: CustomEvent<any>;
33
+ blur: CustomEvent<any>;
33
34
  } & {
34
35
  [evt: string]: CustomEvent<any>;
35
36
  };
@@ -107,9 +107,9 @@ $: void reactToMenuShown(menushown);
107
107
  function move(idx) {
108
108
  if (!menushown)
109
109
  return;
110
- while (idx <= lastactive && 'divider' in items[idx])
110
+ while (idx <= lastactive && items[idx] != null && 'divider' in items[idx])
111
111
  idx++;
112
- if (items[idx]?.disabled)
112
+ if (items[idx] == null || items[idx]?.disabled)
113
113
  return;
114
114
  hilited = Math.max(firstactive, Math.min(lastactive, idx));
115
115
  itemelements[hilited].scrollIntoView({ block: 'center' });
@@ -125,7 +125,7 @@ function onkeydown(e) {
125
125
  e.preventDefault();
126
126
  if (menushown) {
127
127
  let i = (hilited ?? firstactive - 1) + 1;
128
- while (!isSelectable(items[i]))
128
+ while (items[i] && !isSelectable(items[i]))
129
129
  i++;
130
130
  move(i);
131
131
  }
@@ -138,7 +138,7 @@ function onkeydown(e) {
138
138
  e.preventDefault();
139
139
  if (menushown) {
140
140
  let i = (hilited ?? lastactive + 1) - 1;
141
- while (!isSelectable(items[i]))
141
+ while (items[i] && !isSelectable(items[i]))
142
142
  i--;
143
143
  move(i);
144
144
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- export * from './components/index.js';
2
1
  export * from './actions/index.js';
2
+ export * from './components/index.js';
3
+ export * from './stores/index.js';
3
4
  export * from './types/index.js';
4
5
  export * from './util/index.js';
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
- export * from './components/index.js';
2
1
  export * from './actions/index.js';
2
+ export * from './components/index.js';
3
+ export * from './stores/index.js';
3
4
  export * from './types/index.js';
4
5
  export * from './util/index.js';
@@ -0,0 +1 @@
1
+ export * from './toasts.js';
@@ -0,0 +1 @@
1
+ export * from './toasts.js';
@@ -0,0 +1,24 @@
1
+ import { Store } from '@txstate-mws/svelte-store';
2
+ export interface Toast {
3
+ message: string;
4
+ /** default is error */
5
+ type?: 'error' | 'warning' | 'info' | 'success';
6
+ /**
7
+ * how long the message should appear on screen in milliseconds
8
+ * default 15 seconds
9
+ * use Number.MAX_SAFE_INTEGER for permanent
10
+ */
11
+ ttlMs?: number;
12
+ }
13
+ export interface ToastStorage extends Required<Toast> {
14
+ /** each toast gets a random id so it can be dismissed by user interaction */
15
+ id: string;
16
+ /** when the toast was created, so we know when it expires */
17
+ stamp: Date;
18
+ }
19
+ export declare class ToastStore extends Store<ToastStorage[]> {
20
+ add(msg: string, type?: Toast['type'], ttlMs?: number): void;
21
+ close(id: string): void;
22
+ clean(): void;
23
+ }
24
+ export declare const toasts: ToastStore;
@@ -0,0 +1,23 @@
1
+ import { Store } from '@txstate-mws/svelte-store';
2
+ import { randomid } from 'txstate-utils';
3
+ export class ToastStore extends Store {
4
+ add(msg, type, ttlMs) {
5
+ const stored = {
6
+ id: randomid(),
7
+ stamp: new Date(),
8
+ message: msg,
9
+ type: type ?? 'error',
10
+ ttlMs: ttlMs ?? 15000
11
+ };
12
+ this.update(v => [...v.filter(t => t.message !== stored.message), stored]);
13
+ setTimeout(() => { this.clean(); }, stored.ttlMs);
14
+ }
15
+ close(id) {
16
+ this.update(v => v.filter(t => t.id !== id));
17
+ }
18
+ clean() {
19
+ const now = new Date().getTime();
20
+ this.update(v => v.filter(t => now - t.stamp.getTime() < t.ttlMs));
21
+ }
22
+ }
23
+ export const toasts = new ToastStore([]);
@@ -1,12 +1,12 @@
1
1
  import type { ElementOffsets } from './bodyoffset.js';
2
2
  export declare function watchForMutations(cb: () => void): {
3
- destroy(): void;
3
+ destroy: () => void;
4
4
  };
5
5
  export declare function watchForPositionChange(el: (HTMLElement | undefined)[] | HTMLElement | undefined, cb: ((offset: Required<ElementOffsets>) => void) | ((offsets: Required<ElementOffsets>[]) => void)): {
6
- update(newEl: (HTMLElement | undefined)[] | HTMLElement | undefined, newCb: ((offset: Required<ElementOffsets>) => void) | ((offsets: Required<ElementOffsets>[]) => void)): void;
6
+ update: (newEl: (HTMLElement | undefined)[] | HTMLElement | undefined, newCb: ((offset: Required<ElementOffsets>) => void) | ((offsets: Required<ElementOffsets>[]) => void)) => void;
7
7
  destroy: () => void;
8
8
  };
9
9
  export declare function watchForPositionChangeInContainer(el: HTMLElement | undefined, container: HTMLElement | undefined, cb: (offset: Required<ElementOffsets>) => void): {
10
- update(newEl: HTMLElement | undefined, newContainer: HTMLElement | undefined, newCb: (offset: Required<ElementOffsets>) => void): void;
10
+ update: (newEl: HTMLElement | undefined, newContainer: HTMLElement | undefined, newCb: (offset: Required<ElementOffsets>) => void) => void;
11
11
  destroy: () => void;
12
12
  };
@@ -21,7 +21,7 @@ export function watchForMutations(cb) {
21
21
  observers.set(id, cb);
22
22
  cb();
23
23
  return {
24
- destroy() {
24
+ destroy: () => {
25
25
  observers.delete(id);
26
26
  if (observers.size === 0) {
27
27
  window.removeEventListener('resize', watch);
@@ -42,7 +42,7 @@ export function watchForPositionChange(el, cb) {
42
42
  }
43
43
  const { destroy } = watchForMutations(watch);
44
44
  return {
45
- update(newEl, newCb) {
45
+ update: (newEl, newCb) => {
46
46
  const changed = newEl !== el || newCb !== cb;
47
47
  if (changed) {
48
48
  el = newEl;
@@ -67,7 +67,7 @@ export function watchForPositionChangeInContainer(el, container, cb) {
67
67
  }
68
68
  const { destroy } = watchForMutations(watch);
69
69
  return {
70
- update(newEl, newContainer, newCb) {
70
+ update: (newEl, newContainer, newCb) => {
71
71
  const changed = newEl !== el || newContainer !== container || newCb !== cb;
72
72
  if (changed) {
73
73
  el = newEl;
package/dist/util/util.js CHANGED
@@ -5,6 +5,7 @@ export function debounced(fn, timeout) {
5
5
  let timer;
6
6
  return (...args) => {
7
7
  clearTimeout(timer);
8
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
8
9
  timer = setTimeout(() => fn(...args), timeout);
9
10
  };
10
11
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@txstate-mws/svelte-components",
3
- "version": "1.5.13",
3
+ "version": "1.6.1",
4
4
  "description": "Svelte components that are generically useful.",
5
5
  "scripts": {
6
6
  "prepublishOnly": "svelte-package",