@bsky.app/sift 0.1.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.
package/.eslintrc.js ADDED
@@ -0,0 +1,5 @@
1
+ module.exports = {
2
+ root: true,
3
+ extends: ['universe/native', 'universe/web'],
4
+ ignorePatterns: ['build'],
5
+ }
package/README.md ADDED
@@ -0,0 +1,135 @@
1
+ # @bsky.app/sift
2
+
3
+ A headless autocomplete UI library for React Native (including web). Uses
4
+ `@floating-ui/react-native` for popover positioning and handles keyboard
5
+ navigation on web.
6
+
7
+ ## Installation
8
+
9
+ ```sh
10
+ pnpm add @bsky.app/sift
11
+ ```
12
+
13
+ Peer dependencies: `react`, `react-native`, `expo`, `@floating-ui/react-native`.
14
+
15
+ ## Basic example
16
+
17
+ ```tsx
18
+ import {useState} from 'react'
19
+ import {View, Text, TextInput} from 'react-native'
20
+ import {useSift, Sift} from '@bsky.app/sift'
21
+
22
+ const items = [
23
+ {key: '1', label: 'Alice'},
24
+ {key: '2', label: 'Bob'},
25
+ {key: '3', label: 'Charlie'},
26
+ ]
27
+
28
+ function Autocomplete() {
29
+ const [query, setQuery] = useState('')
30
+ const sift = useSift({offset: 4})
31
+
32
+ const filtered = items.filter(item =>
33
+ item.label.toLowerCase().includes(query.toLowerCase()),
34
+ )
35
+
36
+ return (
37
+ <View>
38
+ <TextInput
39
+ {...sift.targetProps}
40
+ value={query}
41
+ onChangeText={setQuery}
42
+ placeholder="Search..."
43
+ />
44
+
45
+ {query.length > 0 && (
46
+ <Sift
47
+ sift={sift}
48
+ data={filtered}
49
+ onSelect={item => setQuery(item.label)}
50
+ onDismiss={() => setQuery('')}
51
+ render={({active, item}) => (
52
+ <View style={active && {backgroundColor: '#eee'}}>
53
+ <Text>{item.label}</Text>
54
+ </View>
55
+ )}
56
+ />
57
+ )}
58
+ </View>
59
+ )
60
+ }
61
+ ```
62
+
63
+ Items must have a `key: string` property, used internally for list rendering.
64
+
65
+ ## `useSift` options
66
+
67
+ ### `offset`
68
+
69
+ Gap in pixels between the reference element and the floating popover. Defaults
70
+ to `0`.
71
+
72
+ ```tsx
73
+ const sift = useSift({offset: 8})
74
+ ```
75
+
76
+ ### `placement`
77
+
78
+ Controls where the popover appears relative to the reference element. Accepts
79
+ any `@floating-ui/react-native` placement value (`'bottom'`, `'top'`,
80
+ `'bottom-start'`, etc.). Defaults to `'bottom'`.
81
+
82
+ ```tsx
83
+ const sift = useSift({placement: 'top'})
84
+ ```
85
+
86
+ ## `<Sift>` props
87
+
88
+ ### `onSelect`
89
+
90
+ Called when the user selects an item, either by pressing it or by pressing
91
+ Enter/Tab while it's highlighted via keyboard.
92
+
93
+ ```tsx
94
+ <Sift
95
+ onSelect={item => {
96
+ setQuery(item.label)
97
+ }}
98
+ />
99
+ ```
100
+
101
+ ### `onDismiss`
102
+
103
+ Called when the user presses the Escape key (web only). Use this to close the
104
+ popover or clear the query.
105
+
106
+ ```tsx
107
+ <Sift onDismiss={() => setQuery('')} />
108
+ ```
109
+
110
+ ### `inverted`
111
+
112
+ Renders the list bottom-to-top and reverses keyboard navigation to match.
113
+ Useful when the popover opens above the input.
114
+
115
+ ```tsx
116
+ <Sift
117
+ sift={sift}
118
+ data={filtered}
119
+ inverted
120
+ render={({active, item}) => (
121
+ <View style={active && {backgroundColor: '#eee'}}>
122
+ <Text>{item.label}</Text>
123
+ </View>
124
+ )}
125
+ />
126
+ ```
127
+
128
+ ## Keyboard navigation (web)
129
+
130
+ | Key | Action |
131
+ | ------------------- | --------------------------------------------- |
132
+ | ArrowDown / ArrowUp | Move through items (reversed when `inverted`) |
133
+ | Home / End | Jump to first / last item |
134
+ | Enter / Tab | Select the active item |
135
+ | Escape | Calls `onDismiss` |
@@ -0,0 +1,18 @@
1
+ import { type StyleProp, type ViewStyle } from 'react-native';
2
+ import { type UseSiftReturn } from './useSift';
3
+ export * from './useSift';
4
+ export declare function Sift<Item extends {
5
+ key: string;
6
+ }>({ sift, data, render, style, onSelect, onDismiss, inverted, }: {
7
+ sift: UseSiftReturn;
8
+ data: Item[];
9
+ render: (props: {
10
+ active: boolean;
11
+ item: Item;
12
+ }) => React.ReactElement;
13
+ style?: StyleProp<ViewStyle>;
14
+ onSelect?: (item: Item) => void;
15
+ onDismiss?: () => void;
16
+ inverted?: boolean;
17
+ }): import("react").JSX.Element;
18
+ //# sourceMappingURL=Sift.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Sift.d.ts","sourceRoot":"","sources":["../src/Sift.tsx"],"names":[],"mappings":"AACA,OAAO,EAIL,KAAK,SAAS,EACd,KAAK,SAAS,EACf,MAAM,cAAc,CAAA;AACrB,OAAO,EAAC,KAAK,aAAa,EAAC,MAAM,WAAW,CAAA;AAG5C,cAAc,WAAW,CAAA;AAEzB,wBAAgB,IAAI,CAAC,IAAI,SAAS;IAAC,GAAG,EAAE,MAAM,CAAA;CAAC,EAAE,EAC/C,IAAI,EACJ,IAAI,EACJ,MAAM,EACN,KAAK,EACL,QAAQ,EACR,SAAS,EACT,QAAQ,GACT,EAAE;IACD,IAAI,EAAE,aAAa,CAAA;IACnB,IAAI,EAAE,IAAI,EAAE,CAAA;IACZ,MAAM,EAAE,CAAC,KAAK,EAAE;QAAC,MAAM,EAAE,OAAO,CAAC;QAAC,IAAI,EAAE,IAAI,CAAA;KAAC,KAAK,KAAK,CAAC,YAAY,CAAA;IACpE,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAA;IAC5B,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,IAAI,CAAA;IAC/B,SAAS,CAAC,EAAE,MAAM,IAAI,CAAA;IACtB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB,+BAmEA"}
package/build/Sift.js ADDED
@@ -0,0 +1,57 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { View, FlatList, Pressable, } from 'react-native';
3
+ import { useKeyboardHandling } from './useKeyboardHandling';
4
+ export * from './useSift';
5
+ export function Sift({ sift, data, render, style, onSelect, onDismiss, inverted, }) {
6
+ const [activeIndex, setActiveIndex] = useState(0);
7
+ const activeIndexRef = useRef(activeIndex);
8
+ activeIndexRef.current = activeIndex;
9
+ const updateRef = useRef(sift.internal.update);
10
+ updateRef.current = sift.internal.update;
11
+ useEffect(() => {
12
+ setActiveIndex(0);
13
+ updateRef.current();
14
+ }, [data.length]);
15
+ const next = () => {
16
+ if (data.length === 0)
17
+ return;
18
+ setActiveIndex(i => (i + 1) % data.length);
19
+ };
20
+ const prev = () => {
21
+ if (data.length === 0)
22
+ return;
23
+ setActiveIndex(i => (i - 1 + data.length) % data.length);
24
+ };
25
+ const first = () => {
26
+ if (data.length === 0)
27
+ return;
28
+ setActiveIndex(0);
29
+ };
30
+ const last = () => {
31
+ if (data.length === 0)
32
+ return;
33
+ setActiveIndex(data.length - 1);
34
+ };
35
+ useKeyboardHandling({
36
+ sift,
37
+ onArrowDown: inverted ? prev : next,
38
+ onArrowUp: inverted ? next : prev,
39
+ onHome: inverted ? last : first,
40
+ onEnd: inverted ? first : last,
41
+ onSelect: () => {
42
+ onSelect?.(data[activeIndexRef.current]);
43
+ },
44
+ onDismiss,
45
+ });
46
+ return (<View collapsable={false} ref={sift.internal.refs.setFloating}
47
+ // @ts-ignore
48
+ role="listbox" id={sift.id} style={[sift.internal.floatingStyles, style]}>
49
+ <FlatList data={data} inverted={inverted} keyExtractor={item => item.key} renderItem={items => (<Pressable role="option" aria-selected={items.index === activeIndex} onPress={() => onSelect?.(items.item)}>
50
+ {render({
51
+ active: items.index === activeIndex,
52
+ item: items.item,
53
+ })}
54
+ </Pressable>)}/>
55
+ </View>);
56
+ }
57
+ //# sourceMappingURL=Sift.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Sift.js","sourceRoot":"","sources":["../src/Sift.tsx"],"names":[],"mappings":"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","sourcesContent":["import {useEffect, useRef, useState} from 'react'\nimport {\n View,\n FlatList,\n Pressable,\n type StyleProp,\n type ViewStyle,\n} from 'react-native'\nimport {type UseSiftReturn} from './useSift'\nimport {useKeyboardHandling} from './useKeyboardHandling'\n\nexport * from './useSift'\n\nexport function Sift<Item extends {key: string}>({\n sift,\n data,\n render,\n style,\n onSelect,\n onDismiss,\n inverted,\n}: {\n sift: UseSiftReturn\n data: Item[]\n render: (props: {active: boolean; item: Item}) => React.ReactElement\n style?: StyleProp<ViewStyle>\n onSelect?: (item: Item) => void\n onDismiss?: () => void\n inverted?: boolean\n}) {\n const [activeIndex, setActiveIndex] = useState(0)\n const activeIndexRef = useRef(activeIndex)\n activeIndexRef.current = activeIndex\n const updateRef = useRef(sift.internal.update)\n updateRef.current = sift.internal.update\n\n useEffect(() => {\n setActiveIndex(0)\n updateRef.current()\n }, [data.length])\n\n const next = () => {\n if (data.length === 0) return\n setActiveIndex(i => (i + 1) % data.length)\n }\n const prev = () => {\n if (data.length === 0) return\n setActiveIndex(i => (i - 1 + data.length) % data.length)\n }\n const first = () => {\n if (data.length === 0) return\n setActiveIndex(0)\n }\n const last = () => {\n if (data.length === 0) return\n setActiveIndex(data.length - 1)\n }\n\n useKeyboardHandling({\n sift,\n onArrowDown: inverted ? prev : next,\n onArrowUp: inverted ? next : prev,\n onHome: inverted ? last : first,\n onEnd: inverted ? first : last,\n onSelect: () => {\n onSelect?.(data[activeIndexRef.current])\n },\n onDismiss,\n })\n\n return (\n <View\n collapsable={false}\n ref={sift.internal.refs.setFloating}\n // @ts-ignore\n role=\"listbox\"\n id={sift.id}\n style={[sift.internal.floatingStyles, style]}>\n <FlatList\n data={data}\n inverted={inverted}\n keyExtractor={item => item.key}\n renderItem={items => (\n <Pressable\n role=\"option\"\n aria-selected={items.index === activeIndex}\n onPress={() => onSelect?.(items.item)}>\n {render({\n active: items.index === activeIndex,\n item: items.item,\n })}\n </Pressable>\n )}\n />\n </View>\n )\n}\n"]}
@@ -0,0 +1,3 @@
1
+ export * from './useSift';
2
+ export * from './Sift';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.tsx"],"names":[],"mappings":"AAAA,cAAc,WAAW,CAAA;AACzB,cAAc,QAAQ,CAAA"}
package/build/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export * from './useSift';
2
+ export * from './Sift';
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.tsx"],"names":[],"mappings":"AAAA,cAAc,WAAW,CAAA;AACzB,cAAc,QAAQ,CAAA","sourcesContent":["export * from './useSift'\nexport * from './Sift'\n"]}
@@ -0,0 +1,11 @@
1
+ import { type UseSiftReturn } from './useSift';
2
+ export declare function useKeyboardHandling(_props: {
3
+ sift: UseSiftReturn;
4
+ onArrowDown: () => void;
5
+ onArrowUp: () => void;
6
+ onHome: () => void;
7
+ onEnd: () => void;
8
+ onSelect: () => void;
9
+ onDismiss?: () => void;
10
+ }): void;
11
+ //# sourceMappingURL=useKeyboardHandling.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useKeyboardHandling.d.ts","sourceRoot":"","sources":["../src/useKeyboardHandling.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,KAAK,aAAa,EAAC,MAAM,WAAW,CAAA;AAE5C,wBAAgB,mBAAmB,CAAC,MAAM,EAAE;IAC1C,IAAI,EAAE,aAAa,CAAA;IACnB,WAAW,EAAE,MAAM,IAAI,CAAA;IACvB,SAAS,EAAE,MAAM,IAAI,CAAA;IACrB,MAAM,EAAE,MAAM,IAAI,CAAA;IAClB,KAAK,EAAE,MAAM,IAAI,CAAA;IACjB,QAAQ,EAAE,MAAM,IAAI,CAAA;IACpB,SAAS,CAAC,EAAE,MAAM,IAAI,CAAA;CACvB,QAAI"}
@@ -0,0 +1,2 @@
1
+ export function useKeyboardHandling(_props) { }
2
+ //# sourceMappingURL=useKeyboardHandling.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useKeyboardHandling.js","sourceRoot":"","sources":["../src/useKeyboardHandling.ts"],"names":[],"mappings":"AAEA,MAAM,UAAU,mBAAmB,CAAC,MAQnC,IAAG,CAAC","sourcesContent":["import {type UseSiftReturn} from './useSift'\n\nexport function useKeyboardHandling(_props: {\n sift: UseSiftReturn\n onArrowDown: () => void\n onArrowUp: () => void\n onHome: () => void\n onEnd: () => void\n onSelect: () => void\n onDismiss?: () => void\n}) {}\n"]}
@@ -0,0 +1,11 @@
1
+ import { type UseSiftReturn } from './useSift';
2
+ export declare function useKeyboardHandling(props: {
3
+ sift: UseSiftReturn;
4
+ onArrowDown: () => void;
5
+ onArrowUp: () => void;
6
+ onHome: () => void;
7
+ onEnd: () => void;
8
+ onSelect: () => void;
9
+ onDismiss?: () => void;
10
+ }): void;
11
+ //# sourceMappingURL=useKeyboardHandling.web.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useKeyboardHandling.web.d.ts","sourceRoot":"","sources":["../src/useKeyboardHandling.web.ts"],"names":[],"mappings":"AAEA,OAAO,EAAC,KAAK,aAAa,EAAC,MAAM,WAAW,CAAA;AAE5C,wBAAgB,mBAAmB,CAAC,KAAK,EAAE;IACzC,IAAI,EAAE,aAAa,CAAA;IACnB,WAAW,EAAE,MAAM,IAAI,CAAA;IACvB,SAAS,EAAE,MAAM,IAAI,CAAA;IACrB,MAAM,EAAE,MAAM,IAAI,CAAA;IAClB,KAAK,EAAE,MAAM,IAAI,CAAA;IACjB,QAAQ,EAAE,MAAM,IAAI,CAAA;IACpB,SAAS,CAAC,EAAE,MAAM,IAAI,CAAA;CACvB,QA8CA"}
@@ -0,0 +1,46 @@
1
+ import { useEffect, useRef } from 'react';
2
+ export function useKeyboardHandling(props) {
3
+ const callbacksRef = useRef(props);
4
+ callbacksRef.current = props;
5
+ useEffect(() => {
6
+ const reference = props.sift.internal.elements.reference;
7
+ if (!reference)
8
+ return;
9
+ function onKeyDown(e) {
10
+ if (!callbacksRef.current.sift.internal.elements.floating)
11
+ return;
12
+ switch (e.key) {
13
+ case 'ArrowDown':
14
+ e.preventDefault();
15
+ callbacksRef.current.onArrowDown();
16
+ break;
17
+ case 'ArrowUp':
18
+ e.preventDefault();
19
+ callbacksRef.current.onArrowUp();
20
+ break;
21
+ case 'Enter':
22
+ case 'Tab':
23
+ e.preventDefault();
24
+ callbacksRef.current.onSelect();
25
+ break;
26
+ case 'Home':
27
+ e.preventDefault();
28
+ callbacksRef.current.onHome();
29
+ break;
30
+ case 'End':
31
+ e.preventDefault();
32
+ callbacksRef.current.onEnd();
33
+ break;
34
+ case 'Escape':
35
+ e.preventDefault();
36
+ callbacksRef.current.onDismiss?.();
37
+ break;
38
+ }
39
+ }
40
+ reference.addEventListener('keydown', onKeyDown);
41
+ return () => {
42
+ reference.removeEventListener('keydown', onKeyDown);
43
+ };
44
+ }, [props.sift.internal.elements.reference]);
45
+ }
46
+ //# sourceMappingURL=useKeyboardHandling.web.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useKeyboardHandling.web.js","sourceRoot":"","sources":["../src/useKeyboardHandling.web.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,SAAS,EAAE,MAAM,EAAC,MAAM,OAAO,CAAA;AAIvC,MAAM,UAAU,mBAAmB,CAAC,KAQnC;IACC,MAAM,YAAY,GAAG,MAAM,CAAC,KAAK,CAAC,CAAA;IAClC,YAAY,CAAC,OAAO,GAAG,KAAK,CAAA;IAE5B,SAAS,CAAC,GAAG,EAAE;QACb,MAAM,SAAS,GAAG,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,SAAS,CAAA;QACxD,IAAI,CAAC,SAAS;YAAE,OAAM;QAEtB,SAAS,SAAS,CAAC,CAAgB;YACjC,IAAI,CAAC,YAAY,CAAC,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,QAAQ;gBAAE,OAAM;YAEjE,QAAQ,CAAC,CAAC,GAAG,EAAE,CAAC;gBACd,KAAK,WAAW;oBACd,CAAC,CAAC,cAAc,EAAE,CAAA;oBAClB,YAAY,CAAC,OAAO,CAAC,WAAW,EAAE,CAAA;oBAClC,MAAK;gBACP,KAAK,SAAS;oBACZ,CAAC,CAAC,cAAc,EAAE,CAAA;oBAClB,YAAY,CAAC,OAAO,CAAC,SAAS,EAAE,CAAA;oBAChC,MAAK;gBACP,KAAK,OAAO,CAAC;gBACb,KAAK,KAAK;oBACR,CAAC,CAAC,cAAc,EAAE,CAAA;oBAClB,YAAY,CAAC,OAAO,CAAC,QAAQ,EAAE,CAAA;oBAC/B,MAAK;gBACP,KAAK,MAAM;oBACT,CAAC,CAAC,cAAc,EAAE,CAAA;oBAClB,YAAY,CAAC,OAAO,CAAC,MAAM,EAAE,CAAA;oBAC7B,MAAK;gBACP,KAAK,KAAK;oBACR,CAAC,CAAC,cAAc,EAAE,CAAA;oBAClB,YAAY,CAAC,OAAO,CAAC,KAAK,EAAE,CAAA;oBAC5B,MAAK;gBACP,KAAK,QAAQ;oBACX,CAAC,CAAC,cAAc,EAAE,CAAA;oBAClB,YAAY,CAAC,OAAO,CAAC,SAAS,EAAE,EAAE,CAAA;oBAClC,MAAK;YACT,CAAC;QACH,CAAC;QAED,SAAS,CAAC,gBAAgB,CAAC,SAAS,EAAE,SAAS,CAAC,CAAA;QAEhD,OAAO,GAAG,EAAE;YACV,SAAS,CAAC,mBAAmB,CAAC,SAAS,EAAE,SAAS,CAAC,CAAA;QACrD,CAAC,CAAA;IACH,CAAC,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC,CAAA;AAC9C,CAAC","sourcesContent":["import {useEffect, useRef} from 'react'\n\nimport {type UseSiftReturn} from './useSift'\n\nexport function useKeyboardHandling(props: {\n sift: UseSiftReturn\n onArrowDown: () => void\n onArrowUp: () => void\n onHome: () => void\n onEnd: () => void\n onSelect: () => void\n onDismiss?: () => void\n}) {\n const callbacksRef = useRef(props)\n callbacksRef.current = props\n\n useEffect(() => {\n const reference = props.sift.internal.elements.reference\n if (!reference) return\n\n function onKeyDown(e: KeyboardEvent) {\n if (!callbacksRef.current.sift.internal.elements.floating) return\n\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault()\n callbacksRef.current.onArrowDown()\n break\n case 'ArrowUp':\n e.preventDefault()\n callbacksRef.current.onArrowUp()\n break\n case 'Enter':\n case 'Tab':\n e.preventDefault()\n callbacksRef.current.onSelect()\n break\n case 'Home':\n e.preventDefault()\n callbacksRef.current.onHome()\n break\n case 'End':\n e.preventDefault()\n callbacksRef.current.onEnd()\n break\n case 'Escape':\n e.preventDefault()\n callbacksRef.current.onDismiss?.()\n break\n }\n }\n\n reference.addEventListener('keydown', onKeyDown)\n\n return () => {\n reference.removeEventListener('keydown', onKeyDown)\n }\n }, [props.sift.internal.elements.reference])\n}\n"]}
@@ -0,0 +1,17 @@
1
+ import { type Placement } from '@floating-ui/react-native';
2
+ export type UseSiftReturn = ReturnType<typeof useSift>;
3
+ export declare function useSift({ offset: offsetValue, placement, }: {
4
+ offset?: number;
5
+ placement?: Placement;
6
+ }): {
7
+ id: string;
8
+ internal: import("@floating-ui/react-native").UseFloatingReturn;
9
+ targetProps: {
10
+ ref: (node: any) => void;
11
+ role: "combobox";
12
+ 'aria-controls': string;
13
+ 'aria-expanded': boolean;
14
+ 'aria-autocomplete': "list";
15
+ };
16
+ };
17
+ //# sourceMappingURL=useSift.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useSift.d.ts","sourceRoot":"","sources":["../src/useSift.ts"],"names":[],"mappings":"AACA,OAAO,EAIL,KAAK,SAAS,EACf,MAAM,2BAA2B,CAAA;AAElC,MAAM,MAAM,aAAa,GAAG,UAAU,CAAC,OAAO,OAAO,CAAC,CAAA;AAEtD,wBAAgB,OAAO,CAAC,EACtB,MAAM,EAAE,WAAe,EACvB,SAAS,GACV,EAAE;IACD,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,SAAS,CAAC,EAAE,SAAS,CAAA;CACtB;;;;;;;;;;EAkBA"}
@@ -0,0 +1,21 @@
1
+ import { useId } from 'react';
2
+ import { useFloating, shift, offset, } from '@floating-ui/react-native';
3
+ export function useSift({ offset: offsetValue = 0, placement, }) {
4
+ const id = useId();
5
+ const floating = useFloating({
6
+ middleware: [shift(), offset(offsetValue)],
7
+ placement,
8
+ });
9
+ return {
10
+ id,
11
+ internal: floating,
12
+ targetProps: {
13
+ ref: floating.refs.setReference,
14
+ role: 'combobox',
15
+ 'aria-controls': id,
16
+ 'aria-expanded': !!floating.elements.floating,
17
+ 'aria-autocomplete': 'list',
18
+ },
19
+ };
20
+ }
21
+ //# sourceMappingURL=useSift.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useSift.js","sourceRoot":"","sources":["../src/useSift.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,KAAK,EAAC,MAAM,OAAO,CAAA;AAC3B,OAAO,EACL,WAAW,EACX,KAAK,EACL,MAAM,GAEP,MAAM,2BAA2B,CAAA;AAIlC,MAAM,UAAU,OAAO,CAAC,EACtB,MAAM,EAAE,WAAW,GAAG,CAAC,EACvB,SAAS,GAIV;IACC,MAAM,EAAE,GAAG,KAAK,EAAE,CAAA;IAClB,MAAM,QAAQ,GAAG,WAAW,CAAC;QAC3B,UAAU,EAAE,CAAC,KAAK,EAAE,EAAE,MAAM,CAAC,WAAW,CAAC,CAAC;QAC1C,SAAS;KACV,CAAC,CAAA;IAEF,OAAO;QACL,EAAE;QACF,QAAQ,EAAE,QAAQ;QAClB,WAAW,EAAE;YACX,GAAG,EAAE,QAAQ,CAAC,IAAI,CAAC,YAAY;YAC/B,IAAI,EAAE,UAAmB;YACzB,eAAe,EAAE,EAAE;YACnB,eAAe,EAAE,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,QAAQ;YAC7C,mBAAmB,EAAE,MAAe;SACrC;KACF,CAAA;AACH,CAAC","sourcesContent":["import {useId} from 'react'\nimport {\n useFloating,\n shift,\n offset,\n type Placement,\n} from '@floating-ui/react-native'\n\nexport type UseSiftReturn = ReturnType<typeof useSift>\n\nexport function useSift({\n offset: offsetValue = 0,\n placement,\n}: {\n offset?: number\n placement?: Placement\n}) {\n const id = useId()\n const floating = useFloating({\n middleware: [shift(), offset(offsetValue)],\n placement,\n })\n\n return {\n id,\n internal: floating,\n targetProps: {\n ref: floating.refs.setReference,\n role: 'combobox' as const,\n 'aria-controls': id,\n 'aria-expanded': !!floating.elements.floating,\n 'aria-autocomplete': 'list' as const,\n },\n }\n}\n"]}
@@ -0,0 +1,5 @@
1
+ {
2
+ "platforms": ["apple", "android", "web"],
3
+ "apple": {},
4
+ "android": {}
5
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@bsky.app/sift",
3
+ "version": "0.1.0",
4
+ "description": "My new module",
5
+ "main": "build/index.js",
6
+ "types": "build/index.d.ts",
7
+ "scripts": {
8
+ "build": "expo-module build",
9
+ "clean": "expo-module clean",
10
+ "lint": "expo-module lint",
11
+ "test": "vitest run",
12
+ "prepare": "expo-module prepare",
13
+ "prepublishOnly": "expo-module prepublishOnly",
14
+ "expo-module": "expo-module",
15
+ "open:ios": "xed example/ios",
16
+ "open:android": "open -a \"Android Studio\" example/android"
17
+ },
18
+ "keywords": [
19
+ "react-native",
20
+ "expo",
21
+ "sift",
22
+ "Tapper"
23
+ ],
24
+ "repository": "https://github.com/estrattonbailey/sift",
25
+ "bugs": {
26
+ "url": "https://github.com/estrattonbailey/sift/issues"
27
+ },
28
+ "author": "Bluesky Social PBLLC <git@esb.lol> (https://github.com/estrattonbailey)",
29
+ "license": "MIT",
30
+ "homepage": "https://github.com/estrattonbailey/sift#readme",
31
+ "dependencies": {
32
+ "@floating-ui/react-native": "^0.10.9"
33
+ },
34
+ "devDependencies": {
35
+ "@types/react": "~19.1.1",
36
+ "expo": "^55.0.8",
37
+ "expo-module-scripts": "^55.0.2",
38
+ "react-native": "0.82.1",
39
+ "vitest": "^3.1.1"
40
+ },
41
+ "peerDependencies": {
42
+ "expo": "*",
43
+ "react": "*",
44
+ "react-native": "*"
45
+ }
46
+ }
package/src/Sift.tsx ADDED
@@ -0,0 +1,97 @@
1
+ import {useEffect, useRef, useState} from 'react'
2
+ import {
3
+ View,
4
+ FlatList,
5
+ Pressable,
6
+ type StyleProp,
7
+ type ViewStyle,
8
+ } from 'react-native'
9
+ import {type UseSiftReturn} from './useSift'
10
+ import {useKeyboardHandling} from './useKeyboardHandling'
11
+
12
+ export * from './useSift'
13
+
14
+ export function Sift<Item extends {key: string}>({
15
+ sift,
16
+ data,
17
+ render,
18
+ style,
19
+ onSelect,
20
+ onDismiss,
21
+ inverted,
22
+ }: {
23
+ sift: UseSiftReturn
24
+ data: Item[]
25
+ render: (props: {active: boolean; item: Item}) => React.ReactElement
26
+ style?: StyleProp<ViewStyle>
27
+ onSelect?: (item: Item) => void
28
+ onDismiss?: () => void
29
+ inverted?: boolean
30
+ }) {
31
+ const [activeIndex, setActiveIndex] = useState(0)
32
+ const activeIndexRef = useRef(activeIndex)
33
+ activeIndexRef.current = activeIndex
34
+ const updateRef = useRef(sift.internal.update)
35
+ updateRef.current = sift.internal.update
36
+
37
+ useEffect(() => {
38
+ setActiveIndex(0)
39
+ updateRef.current()
40
+ }, [data.length])
41
+
42
+ const next = () => {
43
+ if (data.length === 0) return
44
+ setActiveIndex(i => (i + 1) % data.length)
45
+ }
46
+ const prev = () => {
47
+ if (data.length === 0) return
48
+ setActiveIndex(i => (i - 1 + data.length) % data.length)
49
+ }
50
+ const first = () => {
51
+ if (data.length === 0) return
52
+ setActiveIndex(0)
53
+ }
54
+ const last = () => {
55
+ if (data.length === 0) return
56
+ setActiveIndex(data.length - 1)
57
+ }
58
+
59
+ useKeyboardHandling({
60
+ sift,
61
+ onArrowDown: inverted ? prev : next,
62
+ onArrowUp: inverted ? next : prev,
63
+ onHome: inverted ? last : first,
64
+ onEnd: inverted ? first : last,
65
+ onSelect: () => {
66
+ onSelect?.(data[activeIndexRef.current])
67
+ },
68
+ onDismiss,
69
+ })
70
+
71
+ return (
72
+ <View
73
+ collapsable={false}
74
+ ref={sift.internal.refs.setFloating}
75
+ // @ts-ignore
76
+ role="listbox"
77
+ id={sift.id}
78
+ style={[sift.internal.floatingStyles, style]}>
79
+ <FlatList
80
+ data={data}
81
+ inverted={inverted}
82
+ keyExtractor={item => item.key}
83
+ renderItem={items => (
84
+ <Pressable
85
+ role="option"
86
+ aria-selected={items.index === activeIndex}
87
+ onPress={() => onSelect?.(items.item)}>
88
+ {render({
89
+ active: items.index === activeIndex,
90
+ item: items.item,
91
+ })}
92
+ </Pressable>
93
+ )}
94
+ />
95
+ </View>
96
+ )
97
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,2 @@
1
+ export * from './useSift'
2
+ export * from './Sift'
@@ -0,0 +1,11 @@
1
+ import {type UseSiftReturn} from './useSift'
2
+
3
+ export function useKeyboardHandling(_props: {
4
+ sift: UseSiftReturn
5
+ onArrowDown: () => void
6
+ onArrowUp: () => void
7
+ onHome: () => void
8
+ onEnd: () => void
9
+ onSelect: () => void
10
+ onDismiss?: () => void
11
+ }) {}
@@ -0,0 +1,59 @@
1
+ import {useEffect, useRef} from 'react'
2
+
3
+ import {type UseSiftReturn} from './useSift'
4
+
5
+ export function useKeyboardHandling(props: {
6
+ sift: UseSiftReturn
7
+ onArrowDown: () => void
8
+ onArrowUp: () => void
9
+ onHome: () => void
10
+ onEnd: () => void
11
+ onSelect: () => void
12
+ onDismiss?: () => void
13
+ }) {
14
+ const callbacksRef = useRef(props)
15
+ callbacksRef.current = props
16
+
17
+ useEffect(() => {
18
+ const reference = props.sift.internal.elements.reference
19
+ if (!reference) return
20
+
21
+ function onKeyDown(e: KeyboardEvent) {
22
+ if (!callbacksRef.current.sift.internal.elements.floating) return
23
+
24
+ switch (e.key) {
25
+ case 'ArrowDown':
26
+ e.preventDefault()
27
+ callbacksRef.current.onArrowDown()
28
+ break
29
+ case 'ArrowUp':
30
+ e.preventDefault()
31
+ callbacksRef.current.onArrowUp()
32
+ break
33
+ case 'Enter':
34
+ case 'Tab':
35
+ e.preventDefault()
36
+ callbacksRef.current.onSelect()
37
+ break
38
+ case 'Home':
39
+ e.preventDefault()
40
+ callbacksRef.current.onHome()
41
+ break
42
+ case 'End':
43
+ e.preventDefault()
44
+ callbacksRef.current.onEnd()
45
+ break
46
+ case 'Escape':
47
+ e.preventDefault()
48
+ callbacksRef.current.onDismiss?.()
49
+ break
50
+ }
51
+ }
52
+
53
+ reference.addEventListener('keydown', onKeyDown)
54
+
55
+ return () => {
56
+ reference.removeEventListener('keydown', onKeyDown)
57
+ }
58
+ }, [props.sift.internal.elements.reference])
59
+ }
package/src/useSift.ts ADDED
@@ -0,0 +1,35 @@
1
+ import {useId} from 'react'
2
+ import {
3
+ useFloating,
4
+ shift,
5
+ offset,
6
+ type Placement,
7
+ } from '@floating-ui/react-native'
8
+
9
+ export type UseSiftReturn = ReturnType<typeof useSift>
10
+
11
+ export function useSift({
12
+ offset: offsetValue = 0,
13
+ placement,
14
+ }: {
15
+ offset?: number
16
+ placement?: Placement
17
+ }) {
18
+ const id = useId()
19
+ const floating = useFloating({
20
+ middleware: [shift(), offset(offsetValue)],
21
+ placement,
22
+ })
23
+
24
+ return {
25
+ id,
26
+ internal: floating,
27
+ targetProps: {
28
+ ref: floating.refs.setReference,
29
+ role: 'combobox' as const,
30
+ 'aria-controls': id,
31
+ 'aria-expanded': !!floating.elements.floating,
32
+ 'aria-autocomplete': 'list' as const,
33
+ },
34
+ }
35
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ // @generated by expo-module-scripts
2
+ {
3
+ "extends": "expo-module-scripts/tsconfig.base",
4
+ "compilerOptions": {
5
+ "outDir": "./build"
6
+ },
7
+ "include": ["./src"],
8
+ "exclude": ["**/__mocks__/*", "**/__tests__/*", "**/__rsc_tests__/*"]
9
+ }
@@ -0,0 +1,7 @@
1
+ import {defineConfig} from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ['src/__tests__/**/*.test.ts'],
6
+ },
7
+ })