playroom 1.2.1 → 1.2.2
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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# playroom
|
|
2
2
|
|
|
3
|
+
## 1.2.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#485](https://github.com/seek-oss/playroom/pull/485) [`c1909bf`](https://github.com/seek-oss/playroom/commit/c1909bf380eae30a8223d45ba77dfde2a88b70a3) Thanks [@michaeltaranto](https://github.com/michaeltaranto)! - Snippets: Improve search filtering
|
|
8
|
+
|
|
9
|
+
Ensure that the snippets filtering functionality prioritises `name` over `description` and exact words matches over partial matches.
|
|
10
|
+
To further improve the predictability, Pascal Case names are treated as separate words.
|
|
11
|
+
|
|
3
12
|
## 1.2.1
|
|
4
13
|
|
|
5
14
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -3,6 +3,7 @@ import clsx from 'clsx';
|
|
|
3
3
|
import { Command } from 'cmdk-base';
|
|
4
4
|
import { X } from 'lucide-react';
|
|
5
5
|
import {
|
|
6
|
+
useMemo,
|
|
6
7
|
useState,
|
|
7
8
|
useRef,
|
|
8
9
|
useContext,
|
|
@@ -86,6 +87,125 @@ const SnippetsGroup = ({
|
|
|
86
87
|
<>{children}</>
|
|
87
88
|
);
|
|
88
89
|
|
|
90
|
+
const SnippetItem = ({
|
|
91
|
+
snippet,
|
|
92
|
+
onSelect,
|
|
93
|
+
}: {
|
|
94
|
+
snippet: SnippetWithId;
|
|
95
|
+
onSelect: (snippet: SnippetWithId) => void;
|
|
96
|
+
}) => (
|
|
97
|
+
<Command.Item
|
|
98
|
+
key={snippet.id}
|
|
99
|
+
value={snippet.id}
|
|
100
|
+
onSelect={() => onSelect(snippet)}
|
|
101
|
+
className={styles.snippet}
|
|
102
|
+
>
|
|
103
|
+
<Tooltip
|
|
104
|
+
delay={true}
|
|
105
|
+
open={
|
|
106
|
+
/**
|
|
107
|
+
* Only show tooltip if likely to truncate, i.e. > 50 characters.
|
|
108
|
+
*/
|
|
109
|
+
[snippet.name, snippet.description].join(' ').length < 50
|
|
110
|
+
? false
|
|
111
|
+
: undefined
|
|
112
|
+
}
|
|
113
|
+
side="right"
|
|
114
|
+
sideOffset={16}
|
|
115
|
+
label={
|
|
116
|
+
<>
|
|
117
|
+
{snippet.name}
|
|
118
|
+
<br />
|
|
119
|
+
{snippet.description}
|
|
120
|
+
</>
|
|
121
|
+
}
|
|
122
|
+
trigger={
|
|
123
|
+
<span className={styles.tooltipTrigger}>
|
|
124
|
+
<Text truncate>
|
|
125
|
+
<span className={styles.name}>{snippet.name}</span>{' '}
|
|
126
|
+
<Secondary>{snippet.description}</Secondary>
|
|
127
|
+
</Text>
|
|
128
|
+
</span>
|
|
129
|
+
}
|
|
130
|
+
/>
|
|
131
|
+
</Command.Item>
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const resolveScore = (
|
|
135
|
+
item: string,
|
|
136
|
+
search: string,
|
|
137
|
+
modifier: number = 0
|
|
138
|
+
): number => {
|
|
139
|
+
const lowerItem = item.toLowerCase();
|
|
140
|
+
|
|
141
|
+
if (lowerItem === search) {
|
|
142
|
+
// Is exact match
|
|
143
|
+
return 1 + modifier;
|
|
144
|
+
} else if (
|
|
145
|
+
lowerItem.split(/\s+/).some((word) => word === search) ||
|
|
146
|
+
/*
|
|
147
|
+
* Compare to unmodified item, allowing PascalCase to be treated as separate words.
|
|
148
|
+
* Regex also handles uppercase acronyms, e.g., 'MyHTMLComponent' => ['My', 'HTML', 'Component']
|
|
149
|
+
*/
|
|
150
|
+
item
|
|
151
|
+
.split(/(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/)
|
|
152
|
+
.some((word) => word.toLowerCase() === search)
|
|
153
|
+
) {
|
|
154
|
+
// Contains word that is exact match
|
|
155
|
+
return 0.95 + modifier;
|
|
156
|
+
} else if (lowerItem.startsWith(search)) {
|
|
157
|
+
// Starts with match
|
|
158
|
+
return 0.9 + modifier;
|
|
159
|
+
} else if (lowerItem.split(/\s+/).some((word) => word.startsWith(search))) {
|
|
160
|
+
// Contains word that starts with match
|
|
161
|
+
return 0.85 + modifier;
|
|
162
|
+
} else if (lowerItem.includes(search)) {
|
|
163
|
+
// Contains search character sequence
|
|
164
|
+
return 0.75 + modifier;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return 0;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const scoreSnippet = (snippet: SnippetWithId, search: string): number => {
|
|
171
|
+
const name = snippet.name;
|
|
172
|
+
const description = snippet.description;
|
|
173
|
+
const searchTerm = search.toLowerCase().trim();
|
|
174
|
+
|
|
175
|
+
if (!searchTerm) {
|
|
176
|
+
return 1;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const scoreForName = resolveScore(name, searchTerm);
|
|
180
|
+
if (scoreForName > 0) {
|
|
181
|
+
return scoreForName;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (description) {
|
|
185
|
+
const scoreForDescription = resolveScore(description, searchTerm, -0.04);
|
|
186
|
+
if (scoreForDescription > 0) {
|
|
187
|
+
return scoreForDescription;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Loose subsequence: every character of search must appear in order in value
|
|
192
|
+
let position = 0;
|
|
193
|
+
for (const char of searchTerm) {
|
|
194
|
+
const idx = `${name}${description ? ` ${description}` : ''}`
|
|
195
|
+
.toLowerCase()
|
|
196
|
+
.indexOf(char, position);
|
|
197
|
+
if (idx === -1) {
|
|
198
|
+
return 0;
|
|
199
|
+
}
|
|
200
|
+
position = idx + 1;
|
|
201
|
+
}
|
|
202
|
+
return 0.3;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const allSnippets: SnippetWithId[] = snippetsByGroup.flatMap(
|
|
206
|
+
([, items]) => items
|
|
207
|
+
);
|
|
208
|
+
|
|
89
209
|
const initialMatchedSnippet = ' ';
|
|
90
210
|
const Content = ({ searchRef, onSelect }: SnippetsContentProps) => {
|
|
91
211
|
const [matchedSnippet, setMatchedSnippet] = useState(initialMatchedSnippet);
|
|
@@ -96,12 +216,23 @@ const Content = ({ searchRef, onSelect }: SnippetsContentProps) => {
|
|
|
96
216
|
}, snippetPreviewDebounce);
|
|
97
217
|
|
|
98
218
|
const hasGroups = snippetsByGroup.length > 1;
|
|
219
|
+
const filteredSnippets = useMemo(() => {
|
|
220
|
+
const s = inputValue.trim();
|
|
221
|
+
if (!s) return null;
|
|
222
|
+
return allSnippets
|
|
223
|
+
.map((snippet) => ({ snippet, score: scoreSnippet(snippet, s) }))
|
|
224
|
+
.filter(({ score }) => score > 0)
|
|
225
|
+
.sort((a, b) => b.score - a.score)
|
|
226
|
+
.map(({ snippet }) => snippet);
|
|
227
|
+
}, [inputValue]);
|
|
228
|
+
const isFiltering = filteredSnippets !== null;
|
|
99
229
|
|
|
100
230
|
return (
|
|
101
231
|
<div className={styles.root}>
|
|
102
232
|
<Command
|
|
103
233
|
label="Search snippets"
|
|
104
234
|
loop
|
|
235
|
+
shouldFilter={false}
|
|
105
236
|
value={matchedSnippet}
|
|
106
237
|
onValueChange={(v) => {
|
|
107
238
|
debouncedPreview(snippetsById[v]);
|
|
@@ -143,7 +274,7 @@ const Content = ({ searchRef, onSelect }: SnippetsContentProps) => {
|
|
|
143
274
|
<Command.List
|
|
144
275
|
className={clsx({
|
|
145
276
|
[styles.snippetsContainer]: true,
|
|
146
|
-
[styles.noGroupsVerticalPadding]: !hasGroups,
|
|
277
|
+
[styles.noGroupsVerticalPadding]: !hasGroups || isFiltering,
|
|
147
278
|
[styles.groupHeaderScrollPadding]: hasGroups,
|
|
148
279
|
})}
|
|
149
280
|
label="Filtered snippets"
|
|
@@ -152,47 +283,29 @@ const Content = ({ searchRef, onSelect }: SnippetsContentProps) => {
|
|
|
152
283
|
<Text tone="secondary">No snippets matching “{inputValue}”</Text>
|
|
153
284
|
</Command.Empty>
|
|
154
285
|
|
|
155
|
-
{
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
<Command.Item
|
|
286
|
+
{isFiltering
|
|
287
|
+
? filteredSnippets.map((snippet) => (
|
|
288
|
+
<SnippetItem
|
|
159
289
|
key={snippet.id}
|
|
160
|
-
|
|
161
|
-
onSelect={
|
|
162
|
-
|
|
290
|
+
snippet={snippet}
|
|
291
|
+
onSelect={onSelect}
|
|
292
|
+
/>
|
|
293
|
+
))
|
|
294
|
+
: snippetsByGroup.map(([group, groupSnippets]) => (
|
|
295
|
+
<SnippetsGroup
|
|
296
|
+
key={group}
|
|
297
|
+
enableGroups={hasGroups}
|
|
298
|
+
group={group}
|
|
163
299
|
>
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
: undefined
|
|
173
|
-
}
|
|
174
|
-
side="right"
|
|
175
|
-
sideOffset={16}
|
|
176
|
-
label={
|
|
177
|
-
<>
|
|
178
|
-
{snippet.name}
|
|
179
|
-
<br />
|
|
180
|
-
{snippet.description}
|
|
181
|
-
</>
|
|
182
|
-
}
|
|
183
|
-
trigger={
|
|
184
|
-
<span className={styles.tooltipTrigger}>
|
|
185
|
-
<Text truncate>
|
|
186
|
-
<span className={styles.name}>{snippet.name}</span>{' '}
|
|
187
|
-
<Secondary>{snippet.description}</Secondary>
|
|
188
|
-
</Text>
|
|
189
|
-
</span>
|
|
190
|
-
}
|
|
191
|
-
/>
|
|
192
|
-
</Command.Item>
|
|
300
|
+
{groupSnippets.map((snippet) => (
|
|
301
|
+
<SnippetItem
|
|
302
|
+
key={snippet.id}
|
|
303
|
+
snippet={snippet}
|
|
304
|
+
onSelect={onSelect}
|
|
305
|
+
/>
|
|
306
|
+
))}
|
|
307
|
+
</SnippetsGroup>
|
|
193
308
|
))}
|
|
194
|
-
</SnippetsGroup>
|
|
195
|
-
))}
|
|
196
309
|
</Command.List>
|
|
197
310
|
</Command>
|
|
198
311
|
</div>
|