bits-ui 1.3.13 → 1.3.15
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/menubar/components/menubar-menu.svelte +3 -1
- package/dist/bits/menubar/menubar.svelte.d.ts +5 -3
- package/dist/bits/menubar/menubar.svelte.js +20 -8
- package/dist/bits/menubar/types.d.ts +4 -0
- package/dist/bits/select/select.svelte.js +40 -21
- package/dist/internal/arrays.d.ts +17 -12
- package/dist/internal/arrays.js +58 -13
- 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/dist/internal/dev/visualize-grace-area.d.ts +0 -5
- package/dist/internal/dev/visualize-grace-area.js +0 -28
- package/dist/internal/polygon.d.ts +0 -10
- package/dist/internal/polygon.js +0 -115
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();
|
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
import { useMenubarMenu } from "../menubar.svelte.js";
|
|
5
5
|
import Menu from "../../menu/components/menu.svelte";
|
|
6
6
|
import { useId } from "../../../internal/use-id.js";
|
|
7
|
+
import { noop } from "../../../internal/noop.js";
|
|
7
8
|
|
|
8
|
-
let { value = useId(), ...restProps }: MenubarMenuProps = $props();
|
|
9
|
+
let { value = useId(), onOpenChange = noop, ...restProps }: MenubarMenuProps = $props();
|
|
9
10
|
|
|
10
11
|
const menuState = useMenubarMenu({
|
|
11
12
|
value: box.with(() => value),
|
|
13
|
+
onOpenChange: box.with(() => onOpenChange),
|
|
12
14
|
});
|
|
13
15
|
</script>
|
|
14
16
|
|
|
@@ -3,7 +3,7 @@ import type { InteractOutsideBehaviorType } from "../utilities/dismissible-layer
|
|
|
3
3
|
import type { ReadableBoxedValues, WritableBoxedValues } from "../../internal/box.svelte.js";
|
|
4
4
|
import { type UseRovingFocusReturn } from "../../internal/use-roving-focus.svelte.js";
|
|
5
5
|
import type { Direction } from "../../shared/index.js";
|
|
6
|
-
import type { BitsFocusEvent, BitsKeyboardEvent, BitsPointerEvent, WithRefProps } from "../../internal/types.js";
|
|
6
|
+
import type { BitsFocusEvent, BitsKeyboardEvent, BitsPointerEvent, OnChangeFn, WithRefProps } from "../../internal/types.js";
|
|
7
7
|
import { type FocusScopeContextValue } from "../utilities/focus-scope/use-focus-scope.svelte.js";
|
|
8
8
|
type MenubarRootStateProps = WithRefProps<ReadableBoxedValues<{
|
|
9
9
|
dir: Direction;
|
|
@@ -16,7 +16,7 @@ declare class MenubarRootState {
|
|
|
16
16
|
rovingFocusGroup: UseRovingFocusReturn;
|
|
17
17
|
wasOpenedByKeyboard: boolean;
|
|
18
18
|
triggerIds: string[];
|
|
19
|
-
|
|
19
|
+
valueToChangeHandler: Map<string, ReadableBox<OnChangeFn<boolean>>>;
|
|
20
20
|
constructor(opts: MenubarRootStateProps);
|
|
21
21
|
/**
|
|
22
22
|
* @param id - the id of the trigger to register
|
|
@@ -28,7 +28,8 @@ declare class MenubarRootState {
|
|
|
28
28
|
* @param contentId - the content id to associate with the value
|
|
29
29
|
* @returns - a function to de-register the menu
|
|
30
30
|
*/
|
|
31
|
-
registerMenu(value: string,
|
|
31
|
+
registerMenu(value: string, onOpenChange: ReadableBox<OnChangeFn<boolean>>): () => void;
|
|
32
|
+
updateValue(value: string): void;
|
|
32
33
|
getTriggers(): HTMLButtonElement[];
|
|
33
34
|
onMenuOpen(id: string, triggerId: string): void;
|
|
34
35
|
onMenuClose(): void;
|
|
@@ -41,6 +42,7 @@ declare class MenubarRootState {
|
|
|
41
42
|
}
|
|
42
43
|
type MenubarMenuStateProps = ReadableBoxedValues<{
|
|
43
44
|
value: string;
|
|
45
|
+
onOpenChange: OnChangeFn<boolean>;
|
|
44
46
|
}>;
|
|
45
47
|
declare class MenubarMenuState {
|
|
46
48
|
readonly opts: MenubarMenuStateProps;
|
|
@@ -13,7 +13,7 @@ class MenubarRootState {
|
|
|
13
13
|
rovingFocusGroup;
|
|
14
14
|
wasOpenedByKeyboard = $state(false);
|
|
15
15
|
triggerIds = $state([]);
|
|
16
|
-
|
|
16
|
+
valueToChangeHandler = new Map();
|
|
17
17
|
constructor(opts) {
|
|
18
18
|
this.opts = opts;
|
|
19
19
|
useRefById(opts);
|
|
@@ -43,12 +43,24 @@ class MenubarRootState {
|
|
|
43
43
|
* @param contentId - the content id to associate with the value
|
|
44
44
|
* @returns - a function to de-register the menu
|
|
45
45
|
*/
|
|
46
|
-
registerMenu(value,
|
|
47
|
-
this.
|
|
46
|
+
registerMenu(value, onOpenChange) {
|
|
47
|
+
this.valueToChangeHandler.set(value, onOpenChange);
|
|
48
48
|
return () => {
|
|
49
|
-
this.
|
|
49
|
+
this.valueToChangeHandler.delete(value);
|
|
50
50
|
};
|
|
51
51
|
}
|
|
52
|
+
updateValue(value) {
|
|
53
|
+
const currValue = this.opts.value.current;
|
|
54
|
+
const currHandler = this.valueToChangeHandler.get(currValue)?.current;
|
|
55
|
+
const nextHandler = this.valueToChangeHandler.get(value)?.current;
|
|
56
|
+
this.opts.value.current = value;
|
|
57
|
+
if (currHandler && currValue !== value) {
|
|
58
|
+
currHandler(false);
|
|
59
|
+
}
|
|
60
|
+
if (nextHandler) {
|
|
61
|
+
nextHandler(true);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
52
64
|
getTriggers() {
|
|
53
65
|
const node = this.opts.ref.current;
|
|
54
66
|
if (!node)
|
|
@@ -56,14 +68,14 @@ class MenubarRootState {
|
|
|
56
68
|
return Array.from(node.querySelectorAll(`[${MENUBAR_TRIGGER_ATTR}]`));
|
|
57
69
|
}
|
|
58
70
|
onMenuOpen(id, triggerId) {
|
|
59
|
-
this.
|
|
71
|
+
this.updateValue(id);
|
|
60
72
|
this.rovingFocusGroup.setCurrentTabStopId(triggerId);
|
|
61
73
|
}
|
|
62
74
|
onMenuClose() {
|
|
63
|
-
this.
|
|
75
|
+
this.updateValue("");
|
|
64
76
|
}
|
|
65
77
|
onMenuToggle(id) {
|
|
66
|
-
this.
|
|
78
|
+
this.updateValue(this.opts.value.current ? "" : id);
|
|
67
79
|
}
|
|
68
80
|
props = $derived.by(() => ({
|
|
69
81
|
id: this.opts.id.current,
|
|
@@ -87,7 +99,7 @@ class MenubarMenuState {
|
|
|
87
99
|
}
|
|
88
100
|
});
|
|
89
101
|
onMount(() => {
|
|
90
|
-
return this.root.registerMenu(this.opts.value.current,
|
|
102
|
+
return this.root.registerMenu(this.opts.value.current, opts.onOpenChange);
|
|
91
103
|
});
|
|
92
104
|
}
|
|
93
105
|
getTriggerNode() {
|
|
@@ -29,6 +29,10 @@ export type MenubarMenuPropsWithoutHTML = WithChildren<{
|
|
|
29
29
|
* within the menubar.
|
|
30
30
|
*/
|
|
31
31
|
value?: string;
|
|
32
|
+
/**
|
|
33
|
+
* A callback that is called when the menu is opened or closed.
|
|
34
|
+
*/
|
|
35
|
+
onOpenChange?: OnChangeFn<boolean>;
|
|
32
36
|
}>;
|
|
33
37
|
export type MenubarMenuProps = MenubarMenuPropsWithoutHTML;
|
|
34
38
|
export type MenubarTriggerPropsWithoutHTML = WithChild<{
|
|
@@ -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) {
|
|
@@ -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
|
/**
|
|
@@ -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
|
}
|
package/package.json
CHANGED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
let graceAreaElement = null;
|
|
2
|
-
let svgContainer = null;
|
|
3
|
-
/**
|
|
4
|
-
* Debugging utility to visualize the grace area of a floating layer component.
|
|
5
|
-
*/
|
|
6
|
-
export function visualizeGraceArea(graceArea) {
|
|
7
|
-
if (graceAreaElement) {
|
|
8
|
-
graceAreaElement.remove();
|
|
9
|
-
}
|
|
10
|
-
if (!svgContainer) {
|
|
11
|
-
svgContainer = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
12
|
-
svgContainer.style.position = "absolute";
|
|
13
|
-
svgContainer.style.top = "0";
|
|
14
|
-
svgContainer.style.left = "0";
|
|
15
|
-
svgContainer.style.width = "100%";
|
|
16
|
-
svgContainer.style.height = "100%";
|
|
17
|
-
svgContainer.style.pointerEvents = "none";
|
|
18
|
-
document.body.appendChild(svgContainer);
|
|
19
|
-
}
|
|
20
|
-
graceAreaElement = document.createElementNS("http://www.w3.org/2000/svg", "polygon");
|
|
21
|
-
const pointsString = graceArea.map((p) => `${p.x},${p.y}`).join(" ");
|
|
22
|
-
graceAreaElement.setAttribute("points", pointsString);
|
|
23
|
-
graceAreaElement.setAttribute("fill", "rgba(255, 0, 0, 0.3)");
|
|
24
|
-
graceAreaElement.setAttribute("stroke", "red");
|
|
25
|
-
graceAreaElement.setAttribute("stroke-width", "1");
|
|
26
|
-
graceAreaElement.style.pointerEvents = "none";
|
|
27
|
-
svgContainer.appendChild(graceAreaElement);
|
|
28
|
-
}
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
export interface Point {
|
|
2
|
-
x: number;
|
|
3
|
-
y: number;
|
|
4
|
-
}
|
|
5
|
-
export type Polygon = Array<Point>;
|
|
6
|
-
export declare function makeHullPresorted<P extends Point>(points: Readonly<Array<P>>): Array<P>;
|
|
7
|
-
export declare function POINT_COMPARATOR(a: Point, b: Point): number;
|
|
8
|
-
export declare function makeHullFromElements(els: Array<HTMLElement>): Array<Point>;
|
|
9
|
-
export declare function pointInPolygon(point: Point, polygon: Polygon): boolean;
|
|
10
|
-
export declare function isPointerInGraceArea(e: Pick<PointerEvent, "clientX" | "clientY">, area?: Polygon): boolean;
|
package/dist/internal/polygon.js
DELETED
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Convex hull algorithm - Library (TypeScript)
|
|
3
|
-
*
|
|
4
|
-
* Copyright (c) 2021 Project Nayuki
|
|
5
|
-
* https://www.nayuki.io/page/convex-hull-algorithm
|
|
6
|
-
*
|
|
7
|
-
* This program is free software: you can redistribute it and/or modify
|
|
8
|
-
* it under the terms of the GNU Lesser General Public License as published by
|
|
9
|
-
* the Free Software Foundation, either version 3 of the License, or
|
|
10
|
-
* (at your option) any later version.
|
|
11
|
-
*
|
|
12
|
-
* This program is distributed in the hope that it will be useful,
|
|
13
|
-
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
14
|
-
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
15
|
-
* GNU Lesser General Public License for more details.
|
|
16
|
-
*
|
|
17
|
-
* You should have received a copy of the GNU Lesser General Public License
|
|
18
|
-
* along with this program (see COPYING.txt and COPYING.LESSER.txt).
|
|
19
|
-
* If not, see <http://www.gnu.org/licenses/>.
|
|
20
|
-
*/
|
|
21
|
-
// Returns a new array of points representing the convex hull of
|
|
22
|
-
// the given set of points. The convex hull excludes collinear points.
|
|
23
|
-
// This algorithm runs in O(n log n) time.
|
|
24
|
-
function makeHull(points) {
|
|
25
|
-
const newPoints = points.slice();
|
|
26
|
-
newPoints.sort(POINT_COMPARATOR);
|
|
27
|
-
return makeHullPresorted(newPoints);
|
|
28
|
-
}
|
|
29
|
-
// Returns the convex hull, assuming that each points[i] <= points[i + 1]. Runs in O(n) time.
|
|
30
|
-
export function makeHullPresorted(points) {
|
|
31
|
-
if (points.length <= 1)
|
|
32
|
-
return points.slice();
|
|
33
|
-
// Andrew's monotone chain algorithm. Positive y coordinates correspond to "up"
|
|
34
|
-
// as per the mathematical convention, instead of "down" as per the computer
|
|
35
|
-
// graphics convention. This doesn't affect the correctness of the result.
|
|
36
|
-
const upperHull = [];
|
|
37
|
-
for (let i = 0; i < points.length; i++) {
|
|
38
|
-
const p = points[i];
|
|
39
|
-
while (upperHull.length >= 2) {
|
|
40
|
-
const q = upperHull[upperHull.length - 1];
|
|
41
|
-
const r = upperHull[upperHull.length - 2];
|
|
42
|
-
if ((q.x - r.x) * (p.y - r.y) >= (q.y - r.y) * (p.x - r.x))
|
|
43
|
-
upperHull.pop();
|
|
44
|
-
else
|
|
45
|
-
break;
|
|
46
|
-
}
|
|
47
|
-
upperHull.push(p);
|
|
48
|
-
}
|
|
49
|
-
upperHull.pop();
|
|
50
|
-
const lowerHull = [];
|
|
51
|
-
for (let i = points.length - 1; i >= 0; i--) {
|
|
52
|
-
const p = points[i];
|
|
53
|
-
while (lowerHull.length >= 2) {
|
|
54
|
-
const q = lowerHull[lowerHull.length - 1];
|
|
55
|
-
const r = lowerHull[lowerHull.length - 2];
|
|
56
|
-
if ((q.x - r.x) * (p.y - r.y) >= (q.y - r.y) * (p.x - r.x))
|
|
57
|
-
lowerHull.pop();
|
|
58
|
-
else
|
|
59
|
-
break;
|
|
60
|
-
}
|
|
61
|
-
lowerHull.push(p);
|
|
62
|
-
}
|
|
63
|
-
lowerHull.pop();
|
|
64
|
-
if (upperHull.length === 1 &&
|
|
65
|
-
lowerHull.length === 1 &&
|
|
66
|
-
upperHull[0].x === lowerHull[0].x &&
|
|
67
|
-
upperHull[0].y === lowerHull[0].y)
|
|
68
|
-
return upperHull;
|
|
69
|
-
else
|
|
70
|
-
return upperHull.concat(lowerHull);
|
|
71
|
-
}
|
|
72
|
-
export function POINT_COMPARATOR(a, b) {
|
|
73
|
-
if (a.x < b.x)
|
|
74
|
-
return -1;
|
|
75
|
-
else if (a.x > b.x)
|
|
76
|
-
return +1;
|
|
77
|
-
else if (a.y < b.y)
|
|
78
|
-
return -1;
|
|
79
|
-
else if (a.y > b.y)
|
|
80
|
-
return +1;
|
|
81
|
-
else
|
|
82
|
-
return 0;
|
|
83
|
-
}
|
|
84
|
-
function getPointsFromEl(el) {
|
|
85
|
-
const rect = el.getBoundingClientRect();
|
|
86
|
-
return [
|
|
87
|
-
{ x: rect.left, y: rect.top },
|
|
88
|
-
{ x: rect.right, y: rect.top },
|
|
89
|
-
{ x: rect.right, y: rect.bottom },
|
|
90
|
-
{ x: rect.left, y: rect.bottom },
|
|
91
|
-
];
|
|
92
|
-
}
|
|
93
|
-
export function makeHullFromElements(els) {
|
|
94
|
-
const points = els.flatMap((el) => getPointsFromEl(el));
|
|
95
|
-
return makeHull(points);
|
|
96
|
-
}
|
|
97
|
-
export function pointInPolygon(point, polygon) {
|
|
98
|
-
let inside = false;
|
|
99
|
-
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
100
|
-
const xi = polygon[i].x;
|
|
101
|
-
const yi = polygon[i].y;
|
|
102
|
-
const xj = polygon[j].x;
|
|
103
|
-
const yj = polygon[j].y;
|
|
104
|
-
const intersect = yi > point.y !== yj > point.y &&
|
|
105
|
-
point.x < ((xj - xi) * (point.y - yi)) / (yj - yi) + xi;
|
|
106
|
-
if (intersect)
|
|
107
|
-
inside = !inside;
|
|
108
|
-
}
|
|
109
|
-
return inside;
|
|
110
|
-
}
|
|
111
|
-
export function isPointerInGraceArea(e, area) {
|
|
112
|
-
if (!area)
|
|
113
|
-
return false;
|
|
114
|
-
return pointInPolygon({ x: e.clientX, y: e.clientY }, area);
|
|
115
|
-
}
|