@referralgps/selectra 1.0.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.
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Sifter - A library for textually searching arrays and hashes of objects.
3
+ * Designed specifically for autocomplete. Supports diacritics, smart scoring,
4
+ * multi-field sorting, and nested properties.
5
+ *
6
+ * Originally by Brian Reavis, modernized as ES module.
7
+ */
8
+
9
+ const DIACRITICS = {
10
+ a: '[aḀḁĂăÂâǍǎȺⱥȦȧẠạÄäÀàÁáĀāÃãÅåąĄÃąĄ]',
11
+ b: '[b␢βΒB฿𐌁ᛒ]',
12
+ c: '[cĆćĈĉČčĊċC̄c̄ÇçḈḉȻȼƇƈɕᴄCc]',
13
+ d: '[dĎďḊḋḐḑḌḍḒḓḎḏĐđD̦d̦ƉɖƊɗƋƌᵭᶁᶑȡᴅDdð]',
14
+ e: '[eÉéÈèÊêḘḙĚěĔĕẼẽḚḛẺẻĖėËëĒēȨȩĘęᶒɆɇȄȅẾếỀềỄễỂểḜḝḖḗḔḕȆȇẸẹỆệⱸᴇEeɘǝƏƐε]',
15
+ f: '[fƑƒḞḟ]',
16
+ g: '[gɢ₲ǤǥĜĝĞğĢģƓɠĠġ]',
17
+ h: '[hĤĥĦħḨḩẖẖḤḥḢḣɦʰǶƕ]',
18
+ i: '[iÍíÌìĬĭÎîǏǐÏïḮḯĨĩĮįĪīỈỉȈȉȊȋỊịḬḭƗɨɨ̆ᵻᶖİiIıɪIi]',
19
+ j: '[jȷĴĵɈɉʝɟʲ]',
20
+ k: '[kƘƙꝀꝁḰḱǨǩḲḳḴḵκϰ₭]',
21
+ l: '[lŁłĽľĻļĹĺḶḷḸḹḼḽḺḻĿŀȽƚⱠⱡⱢɫɬᶅɭȴʟLl]',
22
+ n: '[nŃńǸǹŇňÑñṄṅŅņṆṇṊṋṈṉN̈n̈ƝɲȠƞᵰᶇɳȵɴNnŊŋ]',
23
+ o: '[oØøÖöÓóÒòÔôǑǒŐőŎŏȮȯỌọƟɵƠơỎỏŌōÕõǪǫȌȍՕօ]',
24
+ p: '[pṔṕṖṗⱣᵽƤƥᵱ]',
25
+ q: '[qꝖꝗʠɊɋꝘꝙq̃]',
26
+ r: '[rŔŕɌɍŘřŖŗṘṙȐȑȒȓṚṛⱤɽ]',
27
+ s: '[sŚśṠṡṢṣꞨꞩŜŝŠšŞşȘșS̈s̈]',
28
+ t: '[tŤťṪṫŢţṬṭƮʈȚțṰṱṮṯƬƭ]',
29
+ u: '[uŬŭɄʉỤụÜüÚúÙùÛûǓǔŰűŬŭƯưỦủŪūŨũŲųȔȕ∪]',
30
+ v: '[vṼṽṾṿƲʋꝞꝟⱱʋ]',
31
+ w: '[wẂẃẀẁŴŵẄẅẆẇẈẉ]',
32
+ x: '[xẌẍẊẋχ]',
33
+ y: '[yÝýỲỳŶŷŸÿỸỹẎẏỴỵɎɏƳƴ]',
34
+ z: '[zŹźẐẑŽžŻżẒẓẔẕƵƶ]',
35
+ };
36
+
37
+ const asciifold = (() => {
38
+ const lookup = {};
39
+ let i18nChars = '';
40
+ for (const k in DIACRITICS) {
41
+ const chunk = DIACRITICS[k].substring(2, DIACRITICS[k].length - 1);
42
+ i18nChars += chunk;
43
+ for (let i = 0; i < chunk.length; i++) {
44
+ lookup[chunk.charAt(i)] = k;
45
+ }
46
+ }
47
+ const regexp = new RegExp('[' + i18nChars + ']', 'g');
48
+ return (str) =>
49
+ str
50
+ .replace(regexp, (ch) => lookup[ch] || '')
51
+ .toLowerCase();
52
+ })();
53
+
54
+ function escapeRegex(str) {
55
+ return (str + '').replace(/([.?*+^$[\]\\(){}|-])/g, '\\$1');
56
+ }
57
+
58
+ function getAttr(obj, name, nesting) {
59
+ if (!obj || !name) return undefined;
60
+ if (!nesting) return obj[name];
61
+ const names = name.split('.');
62
+ let current = obj;
63
+ while (names.length && current) {
64
+ current = current[names.shift()];
65
+ }
66
+ return current;
67
+ }
68
+
69
+ function cmp(a, b) {
70
+ if (typeof a === 'number' && typeof b === 'number') {
71
+ return a > b ? 1 : a < b ? -1 : 0;
72
+ }
73
+ a = asciifold(String(a || ''));
74
+ b = asciifold(String(b || ''));
75
+ if (a > b) return 1;
76
+ if (b > a) return -1;
77
+ return 0;
78
+ }
79
+
80
+ export default class Sifter {
81
+ constructor(items, settings = {}) {
82
+ this.items = items;
83
+ this.settings = { diacritics: true, ...settings };
84
+ }
85
+
86
+ tokenize(query, respectWordBoundaries) {
87
+ query = String(query || '').toLowerCase().trim();
88
+ if (!query.length) return [];
89
+
90
+ const tokens = [];
91
+ const words = query.split(/\s+/);
92
+
93
+ for (const word of words) {
94
+ let regex = escapeRegex(word);
95
+ if (this.settings.diacritics) {
96
+ for (const letter in DIACRITICS) {
97
+ regex = regex.replace(new RegExp(letter, 'g'), DIACRITICS[letter]);
98
+ }
99
+ }
100
+ if (respectWordBoundaries) regex = '\\b' + regex;
101
+ tokens.push({ string: word, regex: new RegExp(regex, 'i') });
102
+ }
103
+
104
+ return tokens;
105
+ }
106
+
107
+ getScoreFunction(search, options) {
108
+ search = this.prepareSearch(search, options);
109
+ const { tokens } = search;
110
+ const { fields } = search.options;
111
+ const tokenCount = tokens.length;
112
+ const { nesting } = search.options;
113
+
114
+ const scoreValue = (value, token) => {
115
+ if (!value) return 0;
116
+ value = String(value || '');
117
+ const pos = value.search(token.regex);
118
+ if (pos === -1) return 0;
119
+ let score = token.string.length / value.length;
120
+ if (pos === 0) score += 0.5;
121
+ return score;
122
+ };
123
+
124
+ const fieldCount = fields.length;
125
+ const scoreObject =
126
+ fieldCount === 0
127
+ ? () => 0
128
+ : fieldCount === 1
129
+ ? (token, data) => scoreValue(getAttr(data, fields[0], nesting), token)
130
+ : (token, data) => {
131
+ let sum = 0;
132
+ for (let i = 0; i < fieldCount; i++) {
133
+ sum += scoreValue(getAttr(data, fields[i], nesting), token);
134
+ }
135
+ return sum / fieldCount;
136
+ };
137
+
138
+ if (!tokenCount) return () => 0;
139
+ if (tokenCount === 1) return (data) => scoreObject(tokens[0], data);
140
+
141
+ if (search.options.conjunction === 'and') {
142
+ return (data) => {
143
+ let sum = 0;
144
+ for (let i = 0; i < tokenCount; i++) {
145
+ const score = scoreObject(tokens[i], data);
146
+ if (score <= 0) return 0;
147
+ sum += score;
148
+ }
149
+ return sum / tokenCount;
150
+ };
151
+ }
152
+
153
+ return (data) => {
154
+ let sum = 0;
155
+ for (let i = 0; i < tokenCount; i++) {
156
+ sum += scoreObject(tokens[i], data);
157
+ }
158
+ return sum / tokenCount;
159
+ };
160
+ }
161
+
162
+ getSortFunction(search, options) {
163
+ search = this.prepareSearch(search, options);
164
+ const sort = (!search.query && options.sort_empty) || options.sort;
165
+
166
+ const getField = (name, result) => {
167
+ if (name === '$score') return result.score;
168
+ return getAttr(this.items[result.id], name, options.nesting);
169
+ };
170
+
171
+ const fields = [];
172
+ if (sort) {
173
+ for (const s of sort) {
174
+ if (search.query || s.field !== '$score') {
175
+ fields.push(s);
176
+ }
177
+ }
178
+ }
179
+
180
+ if (search.query) {
181
+ let implicitScore = true;
182
+ for (const f of fields) {
183
+ if (f.field === '$score') {
184
+ implicitScore = false;
185
+ break;
186
+ }
187
+ }
188
+ if (implicitScore) fields.unshift({ field: '$score', direction: 'desc' });
189
+ } else {
190
+ const idx = fields.findIndex((f) => f.field === '$score');
191
+ if (idx !== -1) fields.splice(idx, 1);
192
+ }
193
+
194
+ const multipliers = fields.map((f) => (f.direction === 'desc' ? -1 : 1));
195
+ const fieldCount = fields.length;
196
+
197
+ if (!fieldCount) return null;
198
+ if (fieldCount === 1) {
199
+ const field = fields[0].field;
200
+ const mult = multipliers[0];
201
+ return (a, b) => mult * cmp(getField(field, a), getField(field, b));
202
+ }
203
+
204
+ return (a, b) => {
205
+ for (let i = 0; i < fieldCount; i++) {
206
+ const result = multipliers[i] * cmp(getField(fields[i].field, a), getField(fields[i].field, b));
207
+ if (result) return result;
208
+ }
209
+ return 0;
210
+ };
211
+ }
212
+
213
+ prepareSearch(query, options) {
214
+ if (typeof query === 'object') return query;
215
+ options = { ...options };
216
+
217
+ if (options.fields && !Array.isArray(options.fields)) options.fields = [options.fields];
218
+ if (options.sort && !Array.isArray(options.sort)) options.sort = [options.sort];
219
+ if (options.sort_empty && !Array.isArray(options.sort_empty))
220
+ options.sort_empty = [options.sort_empty];
221
+
222
+ return {
223
+ options,
224
+ query: String(query || '').toLowerCase(),
225
+ tokens: this.tokenize(query, options.respect_word_boundaries),
226
+ total: 0,
227
+ items: [],
228
+ };
229
+ }
230
+
231
+ search(query, options) {
232
+ const search = this.prepareSearch(query, options);
233
+ const opts = search.options;
234
+ const q = search.query;
235
+
236
+ const fnScore = opts.score || this.getScoreFunction(search);
237
+
238
+ const items = this.items;
239
+ if (q.length) {
240
+ const iterate = Array.isArray(items)
241
+ ? (cb) => items.forEach((item, i) => cb(item, i))
242
+ : (cb) => Object.keys(items).forEach((key) => cb(items[key], key));
243
+
244
+ iterate((item, id) => {
245
+ const score = fnScore(item);
246
+ if (opts.filter === false || score > 0) {
247
+ search.items.push({ score, id });
248
+ }
249
+ });
250
+ } else {
251
+ const iterate = Array.isArray(items)
252
+ ? (cb) => items.forEach((item, i) => cb(item, i))
253
+ : (cb) => Object.keys(items).forEach((key) => cb(items[key], key));
254
+
255
+ iterate((item, id) => {
256
+ search.items.push({ score: 1, id });
257
+ });
258
+ }
259
+
260
+ const fnSort = this.getSortFunction(search, opts);
261
+ if (fnSort) search.items.sort(fnSort);
262
+
263
+ search.total = search.items.length;
264
+ if (typeof opts.limit === 'number') {
265
+ search.items = search.items.slice(0, opts.limit);
266
+ }
267
+
268
+ return search;
269
+ }
270
+ }
@@ -0,0 +1,277 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ /*
6
+ * Selectra - Tailwind CSS Component Styles
7
+ *
8
+ * These styles use @apply to create component classes that work
9
+ * with dynamically generated markup. Override with Tailwind utilities.
10
+ */
11
+
12
+ @layer components {
13
+ /* ── Wrapper ─────────────────────────────────────────── */
14
+ .selectra-control {
15
+ @apply relative w-full text-sm;
16
+ }
17
+
18
+ .selectra-control.is-disabled {
19
+ @apply opacity-50 pointer-events-none;
20
+ }
21
+
22
+ /* ── Input Wrapper ───────────────────────────────────── */
23
+ .selectra-input {
24
+ @apply relative flex flex-wrap items-center gap-1
25
+ w-full min-h-[38px] px-3 py-1.5
26
+ bg-white border border-gray-300 rounded-lg
27
+ cursor-text transition-all duration-150
28
+ text-gray-900;
29
+ }
30
+
31
+ .selectra-input:hover {
32
+ @apply border-gray-400;
33
+ }
34
+
35
+ .selectra-input.is-focused {
36
+ @apply border-blue-500 ring-2 ring-blue-500/20 outline-none;
37
+ }
38
+
39
+ .selectra-input.is-invalid {
40
+ @apply border-red-500 ring-2 ring-red-500/20;
41
+ }
42
+
43
+ .selectra-input.is-locked {
44
+ @apply bg-gray-50 cursor-default;
45
+ }
46
+
47
+ .selectra-input.is-single {
48
+ @apply cursor-pointer pr-8;
49
+ }
50
+
51
+ .selectra-input.is-single.has-items .selectra-search {
52
+ @apply absolute inset-0 px-3 py-1.5;
53
+ }
54
+
55
+ /* ── Search Input ────────────────────────────────────── */
56
+ .selectra-search {
57
+ @apply flex-1 min-w-[60px] bg-transparent
58
+ outline-none border-none p-0 m-0
59
+ text-gray-900 placeholder-gray-400;
60
+ }
61
+
62
+ .selectra-search:focus {
63
+ @apply outline-none ring-0;
64
+ }
65
+
66
+ .selectra-input.is-single .selectra-search {
67
+ @apply min-w-0 w-full;
68
+ }
69
+
70
+ /* ── Selected Items (Tags) ───────────────────────────── */
71
+ .selectra-item {
72
+ @apply inline-flex items-center gap-1
73
+ px-2 py-0.5 rounded-md
74
+ bg-blue-50 text-blue-700 border border-blue-200
75
+ text-sm leading-snug
76
+ animate-fade-in;
77
+ }
78
+
79
+ .selectra-item .selectra-item-remove {
80
+ @apply inline-flex items-center justify-center
81
+ w-4 h-4 rounded-full
82
+ text-blue-400 hover:text-blue-600
83
+ hover:bg-blue-100
84
+ cursor-pointer transition-colors duration-100
85
+ -mr-0.5 ml-0.5;
86
+ }
87
+
88
+ /* Single select display */
89
+ .selectra-single-value {
90
+ @apply truncate text-gray-900;
91
+ }
92
+
93
+ /* ── Dropdown Arrow ──────────────────────────────────── */
94
+ .selectra-arrow {
95
+ @apply absolute right-2.5 top-1/2 -translate-y-1/2
96
+ w-4 h-4 text-gray-400
97
+ pointer-events-none transition-transform duration-200;
98
+ }
99
+
100
+ .selectra-arrow.is-open {
101
+ @apply rotate-180;
102
+ }
103
+
104
+ /* ── Dropdown ────────────────────────────────────────── */
105
+ .selectra-dropdown {
106
+ @apply absolute z-50 w-full mt-1
107
+ bg-white border border-gray-200 rounded-lg
108
+ shadow-lg overflow-hidden
109
+ animate-dropdown-in;
110
+ }
111
+
112
+ .selectra-dropdown.is-top {
113
+ @apply bottom-full top-auto mb-1 mt-0;
114
+ }
115
+
116
+ .selectra-dropdown-content {
117
+ @apply max-h-60 overflow-y-auto overscroll-contain
118
+ py-1;
119
+ }
120
+
121
+ /* ── Dropdown Option ─────────────────────────────────── */
122
+ .selectra-option {
123
+ @apply px-3 py-2 cursor-pointer
124
+ text-gray-700 transition-colors duration-75
125
+ leading-snug;
126
+ }
127
+
128
+ .selectra-option:hover,
129
+ .selectra-option.is-active {
130
+ @apply bg-blue-500 text-white;
131
+ }
132
+
133
+ .selectra-option.is-active .highlight {
134
+ @apply text-white;
135
+ }
136
+
137
+ .selectra-option.is-disabled {
138
+ @apply opacity-50 cursor-not-allowed;
139
+ }
140
+
141
+ .selectra-option.is-selected {
142
+ @apply bg-blue-50 text-blue-700;
143
+ }
144
+
145
+ .selectra-option.is-selected.is-active {
146
+ @apply bg-blue-500 text-white;
147
+ }
148
+
149
+ /* ── Option Create ───────────────────────────────────── */
150
+ .selectra-option-create {
151
+ @apply px-3 py-2 cursor-pointer
152
+ text-gray-500 transition-colors duration-75
153
+ border-t border-gray-100;
154
+ }
155
+
156
+ .selectra-option-create:hover,
157
+ .selectra-option-create.is-active {
158
+ @apply bg-blue-500 text-white;
159
+ }
160
+
161
+ /* ── No Results ──────────────────────────────────────── */
162
+ .selectra-no-results {
163
+ @apply px-3 py-3 text-center text-gray-400 text-sm;
164
+ }
165
+
166
+ /* ── Loading ─────────────────────────────────────────── */
167
+ .selectra-loading {
168
+ @apply px-3 py-3 text-center text-gray-400 text-sm
169
+ flex items-center justify-center gap-2;
170
+ }
171
+
172
+ .selectra-spinner {
173
+ @apply w-4 h-4 border-2 border-gray-300
174
+ border-t-blue-500 rounded-full animate-spin;
175
+ }
176
+
177
+ /* ── Optgroup ────────────────────────────────────────── */
178
+ .selectra-optgroup-header {
179
+ @apply px-3 py-1.5 text-xs font-semibold
180
+ text-gray-500 uppercase tracking-wider
181
+ bg-gray-50 border-b border-gray-100;
182
+ }
183
+
184
+ .selectra-optgroup + .selectra-optgroup {
185
+ @apply border-t border-gray-100;
186
+ }
187
+
188
+ /* ── Dropdown Header (plugin) ────────────────────────── */
189
+ .selectra-dropdown-header {
190
+ @apply flex items-center justify-between
191
+ px-3 py-2 bg-gray-50
192
+ border-b border-gray-200
193
+ text-sm font-medium text-gray-700;
194
+ }
195
+
196
+ .selectra-dropdown-header .close-btn {
197
+ @apply w-5 h-5 flex items-center justify-center
198
+ rounded text-gray-400 hover:text-gray-600
199
+ hover:bg-gray-200 cursor-pointer transition-colors;
200
+ }
201
+
202
+ /* ── Clear Button (plugin) ───────────────────────────── */
203
+ .selectra-clear-button {
204
+ @apply absolute right-7 top-1/2 -translate-y-1/2
205
+ w-5 h-5 flex items-center justify-center
206
+ rounded-full text-gray-400 hover:text-gray-600
207
+ hover:bg-gray-100 cursor-pointer transition-colors
208
+ z-10;
209
+ }
210
+
211
+ .selectra-input.is-single .selectra-clear-button {
212
+ @apply right-8;
213
+ }
214
+
215
+ /* ── Tag Limit Badge (plugin) ────────────────────────── */
216
+ .selectra-tag-limit-badge {
217
+ @apply inline-flex items-center justify-center
218
+ px-2 py-0.5 rounded-full
219
+ bg-gray-100 text-gray-500
220
+ text-xs font-medium;
221
+ }
222
+
223
+ /* ── Highlight ───────────────────────────────────────── */
224
+ .highlight {
225
+ @apply font-semibold text-inherit;
226
+ }
227
+ }
228
+
229
+ /* ── Animations ──────────────────────────────────────────── */
230
+ @layer utilities {
231
+ @keyframes fade-in {
232
+ from {
233
+ opacity: 0;
234
+ transform: scale(0.95);
235
+ }
236
+ to {
237
+ opacity: 1;
238
+ transform: scale(1);
239
+ }
240
+ }
241
+
242
+ @keyframes dropdown-in {
243
+ from {
244
+ opacity: 0;
245
+ transform: translateY(-4px);
246
+ }
247
+ to {
248
+ opacity: 1;
249
+ transform: translateY(0);
250
+ }
251
+ }
252
+
253
+ .animate-fade-in {
254
+ animation: fade-in 0.15s ease-out;
255
+ }
256
+
257
+ .animate-dropdown-in {
258
+ animation: dropdown-in 0.15s ease-out;
259
+ }
260
+ }
261
+
262
+ /* ── Scrollbar Styling ───────────────────────────────────── */
263
+ .selectra-dropdown-content::-webkit-scrollbar {
264
+ width: 6px;
265
+ }
266
+
267
+ .selectra-dropdown-content::-webkit-scrollbar-track {
268
+ background: transparent;
269
+ }
270
+
271
+ .selectra-dropdown-content::-webkit-scrollbar-thumb {
272
+ @apply bg-gray-200 rounded-full;
273
+ }
274
+
275
+ .selectra-dropdown-content::-webkit-scrollbar-thumb:hover {
276
+ @apply bg-gray-300;
277
+ }