bits-ui 1.3.14 → 1.3.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -4
- package/dist/bits/command/command.svelte.d.ts +2 -0
- package/dist/bits/command/command.svelte.js +19 -3
- package/dist/bits/select/select.svelte.js +40 -21
- package/dist/bits/slider/slider.svelte.d.ts +2 -2
- package/dist/bits/slider/slider.svelte.js +30 -13
- package/dist/internal/arrays.d.ts +17 -12
- package/dist/internal/arrays.js +58 -13
- package/dist/internal/math.d.ts +1 -0
- package/dist/internal/math.js +15 -0
- package/dist/internal/use-data-typeahead.svelte.d.ts +3 -1
- package/dist/internal/use-data-typeahead.svelte.js +2 -1
- package/dist/internal/use-dom-typeahead.svelte.js +3 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,17 +5,22 @@
|
|
|
5
5
|
[](https://npmjs.com/package/bits-ui)
|
|
6
6
|
[](https://npmjs.com/package/bits-ui)
|
|
7
7
|
[](https://github.com/huntabyte/bits-ui/blob/main/LICENSE)
|
|
8
|
+
[](https://discord.gg/fdXy3Sk8Gq)
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
<img style="max-width: 100%" alt="hero" src="https://github.com/user-attachments/assets/19cac792-6a93-4289-b9c7-647794a7de79" />
|
|
11
|
+
|
|
12
|
+
**Bits UI** – the headless components for Svelte.
|
|
10
13
|
|
|
11
|
-
|
|
14
|
+
Flexible, unstyled, and accessible primitives that provide the foundation for building your own high-quality component library.
|
|
12
15
|
|
|
13
|
-
|
|
16
|
+
## Documentation
|
|
17
|
+
|
|
18
|
+
Visit https://bits-ui.com/docs to view the documentation.
|
|
14
19
|
|
|
15
20
|
## Credits
|
|
16
21
|
|
|
17
22
|
- [Bitworks](https://bitworks.cz) - The design team behind the Bits UI documentation and example components.
|
|
18
|
-
- [Melt UI](https://melt-ui.com) -
|
|
23
|
+
- [Melt UI](https://melt-ui.com) - A powerful builder API that inspired the internal architecture of Bits UI.
|
|
19
24
|
- [Radix UI](https://radix-ui.com) - The incredible headless component APIs that we've taken heavy inspiration from.
|
|
20
25
|
- [React Spectrum](https://react-spectrum.adobe.com) - An incredible collection of headless components we've taken inspiration from.
|
|
21
26
|
|
|
@@ -41,3 +46,14 @@ Built by [@huntabyte](https://github.com/huntabyte) and [community](https://gith
|
|
|
41
46
|
</a>
|
|
42
47
|
|
|
43
48
|
<!-- /automd -->
|
|
49
|
+
|
|
50
|
+
## Community
|
|
51
|
+
|
|
52
|
+
Join the Discord server to ask questions, find collaborators, or just say hi!
|
|
53
|
+
|
|
54
|
+
<a href="https://discord.gg/fdXy3Sk8Gq" alt="Svecosystem Discord community">
|
|
55
|
+
<picture>
|
|
56
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://invidget.switchblade.xyz/fdXy3Sk8Gq">
|
|
57
|
+
<img alt="Svecosystem Discord community" src="https://invidget.switchblade.xyz/fdXy3Sk8Gq?theme=light">
|
|
58
|
+
</picture>
|
|
59
|
+
</a>
|
|
@@ -14,6 +14,8 @@ type CommandRootStateProps = WithRefProps<ReadableBoxedValues<{
|
|
|
14
14
|
declare class CommandRootState {
|
|
15
15
|
#private;
|
|
16
16
|
readonly opts: CommandRootStateProps;
|
|
17
|
+
sortAfterTick: boolean;
|
|
18
|
+
sortAndFilterAfterTick: boolean;
|
|
17
19
|
allItems: Set<string>;
|
|
18
20
|
allGroups: Map<string, Set<string>>;
|
|
19
21
|
allIds: Map<string, {
|
|
@@ -32,6 +32,8 @@ const CommandGroupContainerContext = new Context("Command.Group");
|
|
|
32
32
|
class CommandRootState {
|
|
33
33
|
opts;
|
|
34
34
|
#updateScheduled = false;
|
|
35
|
+
sortAfterTick = false;
|
|
36
|
+
sortAndFilterAfterTick = false;
|
|
35
37
|
allItems = new Set();
|
|
36
38
|
allGroups = new Map();
|
|
37
39
|
allIds = new Map();
|
|
@@ -401,7 +403,14 @@ class CommandRootState {
|
|
|
401
403
|
return noop;
|
|
402
404
|
this.allIds.set(id, { value, keywords });
|
|
403
405
|
this._commandState.filtered.items.set(id, this.#score(value, keywords));
|
|
404
|
-
this
|
|
406
|
+
// Schedule sorting to run after this tick when all items are added not each time an item is added
|
|
407
|
+
if (!this.sortAfterTick) {
|
|
408
|
+
this.sortAfterTick = true;
|
|
409
|
+
afterTick(() => {
|
|
410
|
+
this.#sort();
|
|
411
|
+
this.sortAfterTick = false;
|
|
412
|
+
});
|
|
413
|
+
}
|
|
405
414
|
return () => {
|
|
406
415
|
this.allIds.delete(id);
|
|
407
416
|
};
|
|
@@ -425,8 +434,15 @@ class CommandRootState {
|
|
|
425
434
|
this.allGroups.get(groupId).add(id);
|
|
426
435
|
}
|
|
427
436
|
}
|
|
428
|
-
this
|
|
429
|
-
this
|
|
437
|
+
// Schedule sorting and filtering to run after this tick when all items are added not each time an item is added
|
|
438
|
+
if (!this.sortAndFilterAfterTick) {
|
|
439
|
+
this.sortAndFilterAfterTick = true;
|
|
440
|
+
afterTick(() => {
|
|
441
|
+
this.#filterItems();
|
|
442
|
+
this.#sort();
|
|
443
|
+
this.sortAndFilterAfterTick = false;
|
|
444
|
+
});
|
|
445
|
+
}
|
|
430
446
|
this.#scheduleUpdate();
|
|
431
447
|
return () => {
|
|
432
448
|
const selectedItem = this.#getSelectedItem();
|
|
@@ -423,6 +423,7 @@ class SelectTriggerState {
|
|
|
423
423
|
this.root.opts.value.current = matchedItem.value;
|
|
424
424
|
},
|
|
425
425
|
enabled: !this.root.isMulti && this.root.dataTypeaheadEnabled,
|
|
426
|
+
candidateValues: () => (this.root.isMulti ? [] : this.root.candidateLabels),
|
|
426
427
|
});
|
|
427
428
|
this.onkeydown = this.onkeydown.bind(this);
|
|
428
429
|
this.onpointerdown = this.onpointerdown.bind(this);
|
|
@@ -437,6 +438,29 @@ class SelectTriggerState {
|
|
|
437
438
|
#handlePointerOpen(_) {
|
|
438
439
|
this.#handleOpen();
|
|
439
440
|
}
|
|
441
|
+
/**
|
|
442
|
+
* Logic used to handle keyboard selection/deselection.
|
|
443
|
+
*
|
|
444
|
+
* If it returns true, it means the item was selected and whatever is calling
|
|
445
|
+
* this function should return early
|
|
446
|
+
*
|
|
447
|
+
*/
|
|
448
|
+
#handleKeyboardSelection() {
|
|
449
|
+
const isCurrentSelectedValue = this.root.highlightedValue === this.root.opts.value.current;
|
|
450
|
+
if (!this.root.opts.allowDeselect.current && isCurrentSelectedValue && !this.root.isMulti) {
|
|
451
|
+
this.root.handleClose();
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
// "" is a valid value for a select item so we need to check for that
|
|
455
|
+
if (this.root.highlightedValue !== null) {
|
|
456
|
+
this.root.toggleItem(this.root.highlightedValue, this.root.highlightedLabel ?? undefined);
|
|
457
|
+
}
|
|
458
|
+
if (!this.root.isMulti && !isCurrentSelectedValue) {
|
|
459
|
+
this.root.handleClose();
|
|
460
|
+
return true;
|
|
461
|
+
}
|
|
462
|
+
return false;
|
|
463
|
+
}
|
|
440
464
|
onkeydown(e) {
|
|
441
465
|
this.root.isUsingKeyboard = true;
|
|
442
466
|
if (e.key === kbd.ARROW_UP || e.key === kbd.ARROW_DOWN)
|
|
@@ -450,7 +474,7 @@ class SelectTriggerState {
|
|
|
450
474
|
this.root.handleOpen();
|
|
451
475
|
}
|
|
452
476
|
else if (!this.root.isMulti && this.root.dataTypeaheadEnabled) {
|
|
453
|
-
this.#dataTypeahead.handleTypeaheadSearch(e.key
|
|
477
|
+
this.#dataTypeahead.handleTypeaheadSearch(e.key);
|
|
454
478
|
return;
|
|
455
479
|
}
|
|
456
480
|
// we need to wait for a tick after the menu opens to ensure
|
|
@@ -474,23 +498,16 @@ class SelectTriggerState {
|
|
|
474
498
|
this.root.handleClose();
|
|
475
499
|
return;
|
|
476
500
|
}
|
|
477
|
-
if ((e.key === kbd.ENTER ||
|
|
501
|
+
if ((e.key === kbd.ENTER ||
|
|
502
|
+
// if we're currently "typing ahead", we don't want to select the item
|
|
503
|
+
// just yet as the item the user is trying to get to may have a space in it,
|
|
504
|
+
// so we defer handling the close for this case until further down
|
|
505
|
+
(e.key === kbd.SPACE && this.#domTypeahead.search.current === "")) &&
|
|
506
|
+
!e.isComposing) {
|
|
478
507
|
e.preventDefault();
|
|
479
|
-
const
|
|
480
|
-
if (
|
|
481
|
-
isCurrentSelectedValue &&
|
|
482
|
-
!this.root.isMulti) {
|
|
483
|
-
this.root.handleClose();
|
|
484
|
-
return;
|
|
485
|
-
}
|
|
486
|
-
//"" is a valid value for a select item so we need to check for that
|
|
487
|
-
if (this.root.highlightedValue !== null) {
|
|
488
|
-
this.root.toggleItem(this.root.highlightedValue, this.root.highlightedLabel ?? undefined);
|
|
489
|
-
}
|
|
490
|
-
if (!this.root.isMulti && !isCurrentSelectedValue) {
|
|
491
|
-
this.root.handleClose();
|
|
508
|
+
const shouldReturn = this.#handleKeyboardSelection();
|
|
509
|
+
if (shouldReturn)
|
|
492
510
|
return;
|
|
493
|
-
}
|
|
494
511
|
}
|
|
495
512
|
if (e.key === kbd.ARROW_UP && e.altKey) {
|
|
496
513
|
this.root.handleClose();
|
|
@@ -529,14 +546,16 @@ class SelectTriggerState {
|
|
|
529
546
|
}
|
|
530
547
|
const isModifierKey = e.ctrlKey || e.altKey || e.metaKey;
|
|
531
548
|
const isCharacterKey = e.key.length === 1;
|
|
532
|
-
|
|
533
|
-
if (e.code === "Space")
|
|
534
|
-
return;
|
|
549
|
+
const isSpaceKey = e.key === kbd.SPACE;
|
|
535
550
|
const candidateNodes = this.root.getCandidateNodes();
|
|
536
551
|
if (e.key === kbd.TAB)
|
|
537
552
|
return;
|
|
538
|
-
if (!isModifierKey && isCharacterKey) {
|
|
539
|
-
this.#domTypeahead.handleTypeaheadSearch(e.key, candidateNodes);
|
|
553
|
+
if (!isModifierKey && (isCharacterKey || isSpaceKey)) {
|
|
554
|
+
const matchedNode = this.#domTypeahead.handleTypeaheadSearch(e.key, candidateNodes);
|
|
555
|
+
if (!matchedNode && isSpaceKey) {
|
|
556
|
+
e.preventDefault();
|
|
557
|
+
this.#handleKeyboardSelection();
|
|
558
|
+
}
|
|
540
559
|
return;
|
|
541
560
|
}
|
|
542
561
|
if (!this.root.highlightedNode) {
|
|
@@ -18,6 +18,8 @@ declare class SliderBaseRootState {
|
|
|
18
18
|
direction: "rl" | "lr" | "tb" | "bt";
|
|
19
19
|
constructor(opts: SliderBaseRootStateProps);
|
|
20
20
|
getAllThumbs: () => HTMLElement[];
|
|
21
|
+
getThumbScale: () => [number, number];
|
|
22
|
+
getPositionFromValue: (thumbValue: number) => number;
|
|
21
23
|
props: {
|
|
22
24
|
readonly id: string;
|
|
23
25
|
readonly "data-orientation": "horizontal" | "vertical";
|
|
@@ -46,7 +48,6 @@ declare class SliderSingleRootState extends SliderBaseRootState {
|
|
|
46
48
|
handlePointerMove: (e: PointerEvent) => void;
|
|
47
49
|
handlePointerDown: (e: PointerEvent) => void;
|
|
48
50
|
handlePointerUp: () => void;
|
|
49
|
-
getPositionFromValue: (thumbValue: number) => number;
|
|
50
51
|
thumbsPropsArr: {
|
|
51
52
|
readonly role: "slider";
|
|
52
53
|
readonly "aria-valuemin": number;
|
|
@@ -98,7 +99,6 @@ declare class SliderMultiRootState extends SliderBaseRootState {
|
|
|
98
99
|
handlePointerMove: (e: PointerEvent) => void;
|
|
99
100
|
handlePointerDown: (e: PointerEvent) => void;
|
|
100
101
|
handlePointerUp: () => void;
|
|
101
|
-
getPositionFromValue: (thumbValue: number) => number;
|
|
102
102
|
getAllThumbs: () => HTMLElement[];
|
|
103
103
|
updateValue: (thumbValue: number, idx: number) => void;
|
|
104
104
|
thumbsPropsArr: {
|
|
@@ -11,7 +11,7 @@ import { getAriaDisabled, getAriaOrientation, getDataDisabled, getDataOrientatio
|
|
|
11
11
|
import { kbd } from "../../internal/kbd.js";
|
|
12
12
|
import { isElementOrSVGElement } from "../../internal/is.js";
|
|
13
13
|
import { isValidIndex } from "../../internal/arrays.js";
|
|
14
|
-
import { snapValueToStep } from "../../internal/math.js";
|
|
14
|
+
import { linearScale, snapValueToStep } from "../../internal/math.js";
|
|
15
15
|
const SLIDER_ROOT_ATTR = "data-slider-root";
|
|
16
16
|
const SLIDER_THUMB_ATTR = "data-slider-thumb";
|
|
17
17
|
const SLIDER_RANGE_ATTR = "data-slider-range";
|
|
@@ -42,6 +42,32 @@ class SliderBaseRootState {
|
|
|
42
42
|
return [];
|
|
43
43
|
return Array.from(node.querySelectorAll(`[${SLIDER_THUMB_ATTR}]`));
|
|
44
44
|
};
|
|
45
|
+
getThumbScale = () => {
|
|
46
|
+
const isVertical = this.opts.orientation.current === "vertical";
|
|
47
|
+
// this assumes all thumbs are the same width
|
|
48
|
+
const activeThumb = this.getAllThumbs()[0];
|
|
49
|
+
const thumbSize = isVertical ? activeThumb?.offsetHeight : activeThumb?.offsetWidth;
|
|
50
|
+
// if thumb size is undefined or 0, fallback to a 0-100 scale
|
|
51
|
+
if (thumbSize === undefined || Number.isNaN(thumbSize) || thumbSize === 0)
|
|
52
|
+
return [0, 100];
|
|
53
|
+
const trackSize = isVertical
|
|
54
|
+
? this.opts.ref.current?.offsetHeight
|
|
55
|
+
: this.opts.ref.current?.offsetWidth;
|
|
56
|
+
// if track size is undefined or 0, fallback to a 0-100 scale
|
|
57
|
+
if (trackSize === undefined || Number.isNaN(trackSize) || trackSize === 0)
|
|
58
|
+
return [0, 100];
|
|
59
|
+
// the padding on either side
|
|
60
|
+
// half the width of the thumb
|
|
61
|
+
const percentPadding = (thumbSize / 2 / trackSize) * 100;
|
|
62
|
+
const min = percentPadding;
|
|
63
|
+
const max = 100 - percentPadding;
|
|
64
|
+
return [min, max];
|
|
65
|
+
};
|
|
66
|
+
getPositionFromValue = (thumbValue) => {
|
|
67
|
+
const thumbScale = this.getThumbScale();
|
|
68
|
+
const scale = linearScale([this.opts.min.current, this.opts.max.current], thumbScale);
|
|
69
|
+
return scale(thumbValue);
|
|
70
|
+
};
|
|
45
71
|
props = $derived.by(() => ({
|
|
46
72
|
id: this.opts.id.current,
|
|
47
73
|
"data-orientation": getDataOrientation(this.opts.orientation.current),
|
|
@@ -169,16 +195,11 @@ class SliderSingleRootState extends SliderBaseRootState {
|
|
|
169
195
|
}
|
|
170
196
|
this.isActive = false;
|
|
171
197
|
};
|
|
172
|
-
getPositionFromValue = (thumbValue) => {
|
|
173
|
-
const min = this.opts.min.current;
|
|
174
|
-
const max = this.opts.max.current;
|
|
175
|
-
return ((thumbValue - min) / (max - min)) * 100;
|
|
176
|
-
};
|
|
177
198
|
thumbsPropsArr = $derived.by(() => {
|
|
178
199
|
const currValue = this.opts.value.current;
|
|
179
200
|
return Array.from({ length: 1 }, () => {
|
|
180
201
|
const thumbValue = currValue;
|
|
181
|
-
const thumbPosition = this.getPositionFromValue(thumbValue
|
|
202
|
+
const thumbPosition = this.getPositionFromValue(thumbValue);
|
|
182
203
|
const style = getThumbStyles(this.direction, thumbPosition);
|
|
183
204
|
return {
|
|
184
205
|
role: "slider",
|
|
@@ -209,10 +230,11 @@ class SliderSingleRootState extends SliderBaseRootState {
|
|
|
209
230
|
const currValue = this.opts.value.current;
|
|
210
231
|
return Array.from({ length: count }, (_, i) => {
|
|
211
232
|
const tickPosition = i * (step / difference) * 100;
|
|
233
|
+
const scale = linearScale([this.opts.min.current, this.opts.max.current], this.getThumbScale());
|
|
212
234
|
const isFirst = i === 0;
|
|
213
235
|
const isLast = i === count - 1;
|
|
214
236
|
const offsetPercentage = isFirst ? 0 : isLast ? -100 : -50;
|
|
215
|
-
const style = getTickStyles(this.direction, tickPosition, offsetPercentage);
|
|
237
|
+
const style = getTickStyles(this.direction, scale(tickPosition), offsetPercentage);
|
|
216
238
|
const tickValue = min + i * step;
|
|
217
239
|
const bounded = tickValue <= currValue;
|
|
218
240
|
return {
|
|
@@ -379,11 +401,6 @@ class SliderMultiRootState extends SliderBaseRootState {
|
|
|
379
401
|
}
|
|
380
402
|
this.isActive = false;
|
|
381
403
|
};
|
|
382
|
-
getPositionFromValue = (thumbValue) => {
|
|
383
|
-
const min = this.opts.min.current;
|
|
384
|
-
const max = this.opts.max.current;
|
|
385
|
-
return ((thumbValue - min) / (max - min)) * 100;
|
|
386
|
-
};
|
|
387
404
|
getAllThumbs = () => {
|
|
388
405
|
const node = this.opts.ref.current;
|
|
389
406
|
if (!node)
|
|
@@ -66,21 +66,26 @@ export declare function forward<T>(array: T[], index: number, increment: number,
|
|
|
66
66
|
*/
|
|
67
67
|
export declare function backward<T>(array: T[], index: number, decrement: number, loop?: boolean): T | undefined;
|
|
68
68
|
/**
|
|
69
|
-
*
|
|
70
|
-
* the search and the current match, and returns the next match (or `undefined`).
|
|
69
|
+
* Finds the next matching item from a list of values based on a search string.
|
|
71
70
|
*
|
|
72
|
-
*
|
|
73
|
-
* we want the exact same behavior as if we only had that one character
|
|
74
|
-
* (ie. cycle through options starting with that character)
|
|
71
|
+
* This function handles several special cases in typeahead behavior:
|
|
75
72
|
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
* match
|
|
73
|
+
* 1. Space handling: When a search string ends with a space, it handles it specially:
|
|
74
|
+
* - If there's only one match for the text before the space, it ignores the space
|
|
75
|
+
* - If there are multiple matches and the current match already starts with the search prefix
|
|
76
|
+
* followed by a space, it keeps the current match (doesn't change selection on space)
|
|
77
|
+
* - Only after typing characters beyond the space will it move to a more specific match
|
|
79
78
|
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
79
|
+
* 2. Repeated character handling: If a search consists of repeated characters (e.g., "aaa"),
|
|
80
|
+
* it treats it as a single character for matching purposes
|
|
81
|
+
*
|
|
82
|
+
* 3. Cycling behavior: The function wraps around the values array starting from the current match
|
|
83
|
+
* to find the next appropriate match, creating a cycling selection behavior
|
|
84
|
+
*
|
|
85
|
+
* @param values - Array of string values to search through (e.g., the text content of menu items)
|
|
86
|
+
* @param search - The current search string typed by the user
|
|
87
|
+
* @param currentMatch - The currently selected/matched item, if any
|
|
88
|
+
* @returns The next matching value that should be selected, or undefined if no match is found
|
|
84
89
|
*/
|
|
85
90
|
export declare function getNextMatch(values: string[], search: string, currentMatch?: string): string | undefined;
|
|
86
91
|
/**
|
package/dist/internal/arrays.js
CHANGED
|
@@ -169,31 +169,76 @@ export function backward(array, index, decrement, loop = true) {
|
|
|
169
169
|
return array[targetIndex];
|
|
170
170
|
}
|
|
171
171
|
/**
|
|
172
|
-
*
|
|
173
|
-
* the search and the current match, and returns the next match (or `undefined`).
|
|
172
|
+
* Finds the next matching item from a list of values based on a search string.
|
|
174
173
|
*
|
|
175
|
-
*
|
|
176
|
-
* we want the exact same behavior as if we only had that one character
|
|
177
|
-
* (ie. cycle through options starting with that character)
|
|
174
|
+
* This function handles several special cases in typeahead behavior:
|
|
178
175
|
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
* match
|
|
176
|
+
* 1. Space handling: When a search string ends with a space, it handles it specially:
|
|
177
|
+
* - If there's only one match for the text before the space, it ignores the space
|
|
178
|
+
* - If there are multiple matches and the current match already starts with the search prefix
|
|
179
|
+
* followed by a space, it keeps the current match (doesn't change selection on space)
|
|
180
|
+
* - Only after typing characters beyond the space will it move to a more specific match
|
|
182
181
|
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
182
|
+
* 2. Repeated character handling: If a search consists of repeated characters (e.g., "aaa"),
|
|
183
|
+
* it treats it as a single character for matching purposes
|
|
184
|
+
*
|
|
185
|
+
* 3. Cycling behavior: The function wraps around the values array starting from the current match
|
|
186
|
+
* to find the next appropriate match, creating a cycling selection behavior
|
|
187
|
+
*
|
|
188
|
+
* @param values - Array of string values to search through (e.g., the text content of menu items)
|
|
189
|
+
* @param search - The current search string typed by the user
|
|
190
|
+
* @param currentMatch - The currently selected/matched item, if any
|
|
191
|
+
* @returns The next matching value that should be selected, or undefined if no match is found
|
|
187
192
|
*/
|
|
188
193
|
export function getNextMatch(values, search, currentMatch) {
|
|
194
|
+
const lowerSearch = search.toLowerCase();
|
|
195
|
+
if (lowerSearch.endsWith(" ")) {
|
|
196
|
+
const searchWithoutSpace = lowerSearch.slice(0, -1);
|
|
197
|
+
const matchesWithoutSpace = values.filter((value) => value.toLowerCase().startsWith(searchWithoutSpace));
|
|
198
|
+
/**
|
|
199
|
+
* If there's only one match for the prefix without space, we don't
|
|
200
|
+
* watch to match with space.
|
|
201
|
+
*/
|
|
202
|
+
if (matchesWithoutSpace.length <= 1) {
|
|
203
|
+
return getNextMatch(values, searchWithoutSpace, currentMatch);
|
|
204
|
+
}
|
|
205
|
+
const currentMatchLowercase = currentMatch?.toLowerCase();
|
|
206
|
+
/**
|
|
207
|
+
* If the current match already starts with the search prefix and has a space afterward,
|
|
208
|
+
* and the user has only typed up to that space, keep the current match until they
|
|
209
|
+
* disambiguate.
|
|
210
|
+
*/
|
|
211
|
+
if (currentMatchLowercase &&
|
|
212
|
+
currentMatchLowercase.startsWith(searchWithoutSpace) &&
|
|
213
|
+
currentMatchLowercase.charAt(searchWithoutSpace.length) === " " &&
|
|
214
|
+
search.trim() === searchWithoutSpace) {
|
|
215
|
+
return currentMatch;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* With multiple matches, find items that match the full search string with space
|
|
219
|
+
*/
|
|
220
|
+
const spacedMatches = values.filter((value) => value.toLowerCase().startsWith(lowerSearch));
|
|
221
|
+
/**
|
|
222
|
+
* If we found matches with the space, use the first one that's not the current match
|
|
223
|
+
*/
|
|
224
|
+
if (spacedMatches.length > 0) {
|
|
225
|
+
const currentMatchIndex = currentMatch ? values.indexOf(currentMatch) : -1;
|
|
226
|
+
let wrappedMatches = wrapArray(spacedMatches, Math.max(currentMatchIndex, 0));
|
|
227
|
+
// return the first match that is not the current one.
|
|
228
|
+
const nextMatch = wrappedMatches.find((match) => match !== currentMatch);
|
|
229
|
+
// fallback to current if no other is found.
|
|
230
|
+
return nextMatch || currentMatch;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
189
233
|
const isRepeated = search.length > 1 && Array.from(search).every((char) => char === search[0]);
|
|
190
234
|
const normalizedSearch = isRepeated ? search[0] : search;
|
|
235
|
+
const normalizedLowerSearch = normalizedSearch.toLowerCase();
|
|
191
236
|
const currentMatchIndex = currentMatch ? values.indexOf(currentMatch) : -1;
|
|
192
237
|
let wrappedValues = wrapArray(values, Math.max(currentMatchIndex, 0));
|
|
193
238
|
const excludeCurrentMatch = normalizedSearch.length === 1;
|
|
194
239
|
if (excludeCurrentMatch)
|
|
195
240
|
wrappedValues = wrappedValues.filter((v) => v !== currentMatch);
|
|
196
|
-
const nextMatch = wrappedValues.find((value) => value?.toLowerCase().startsWith(
|
|
241
|
+
const nextMatch = wrappedValues.find((value) => value?.toLowerCase().startsWith(normalizedLowerSearch));
|
|
197
242
|
return nextMatch !== currentMatch ? nextMatch : undefined;
|
|
198
243
|
}
|
|
199
244
|
/**
|
package/dist/internal/math.d.ts
CHANGED
|
@@ -2,3 +2,4 @@
|
|
|
2
2
|
* From https://github.com/melt-ui/melt-ui/blob/main/packages/svelte/src/lib/internal/math.ts
|
|
3
3
|
*/
|
|
4
4
|
export declare function snapValueToStep(value: number, min: number, max: number, step: number): number;
|
|
5
|
+
export declare function linearScale(domain: [number, number], range: [number, number], clamp?: boolean): (x: number) => number;
|
package/dist/internal/math.js
CHANGED
|
@@ -26,3 +26,18 @@ export function snapValueToStep(value, min, max, step) {
|
|
|
26
26
|
}
|
|
27
27
|
return snappedValue;
|
|
28
28
|
}
|
|
29
|
+
export function linearScale(domain, range, clamp = true) {
|
|
30
|
+
const [d0, d1] = domain;
|
|
31
|
+
const [r0, r1] = range;
|
|
32
|
+
const slope = (r1 - r0) / (d1 - d0);
|
|
33
|
+
return (x) => {
|
|
34
|
+
const result = r0 + slope * (x - d0);
|
|
35
|
+
if (!clamp)
|
|
36
|
+
return result;
|
|
37
|
+
if (result > Math.max(r0, r1))
|
|
38
|
+
return Math.max(r0, r1);
|
|
39
|
+
if (result < Math.min(r0, r1))
|
|
40
|
+
return Math.min(r0, r1);
|
|
41
|
+
return result;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
+
import type { Getter } from "svelte-toolbelt";
|
|
1
2
|
export type DataTypeahead = ReturnType<typeof useDataTypeahead>;
|
|
2
3
|
type UseDataTypeaheadOpts = {
|
|
3
4
|
onMatch: (value: string) => void;
|
|
4
5
|
getCurrentItem: () => string;
|
|
6
|
+
candidateValues: Getter<string[]>;
|
|
5
7
|
enabled: boolean;
|
|
6
8
|
};
|
|
7
9
|
export declare function useDataTypeahead(opts: UseDataTypeaheadOpts): {
|
|
8
10
|
search: import("svelte-toolbelt").WritableBox<string>;
|
|
9
|
-
handleTypeaheadSearch: (key: string
|
|
11
|
+
handleTypeaheadSearch: (key: string) => string | undefined;
|
|
10
12
|
resetTypeahead: () => void;
|
|
11
13
|
};
|
|
12
14
|
export {};
|
|
@@ -3,7 +3,8 @@ import { boxAutoReset } from "./box-auto-reset.svelte.js";
|
|
|
3
3
|
export function useDataTypeahead(opts) {
|
|
4
4
|
// Reset `search` 1 second after it was last updated
|
|
5
5
|
const search = boxAutoReset("", 1000);
|
|
6
|
-
|
|
6
|
+
const candidateValues = $derived(opts.candidateValues());
|
|
7
|
+
function handleTypeaheadSearch(key) {
|
|
7
8
|
if (!opts.enabled)
|
|
8
9
|
return;
|
|
9
10
|
if (!candidateValues.length)
|
|
@@ -10,10 +10,10 @@ export function useDOMTypeahead(opts) {
|
|
|
10
10
|
return;
|
|
11
11
|
search.current = search.current + key;
|
|
12
12
|
const currentItem = getCurrentItem();
|
|
13
|
-
const currentMatch = candidates.find((item) => item === currentItem)?.textContent
|
|
14
|
-
const values = candidates.map((item) => item.textContent
|
|
13
|
+
const currentMatch = candidates.find((item) => item === currentItem)?.textContent ?? "";
|
|
14
|
+
const values = candidates.map((item) => item.textContent ?? "");
|
|
15
15
|
const nextMatch = getNextMatch(values, search.current, currentMatch);
|
|
16
|
-
const newItem = candidates.find((item) => item.textContent
|
|
16
|
+
const newItem = candidates.find((item) => item.textContent === nextMatch);
|
|
17
17
|
if (newItem) {
|
|
18
18
|
onMatch(newItem);
|
|
19
19
|
}
|