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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "playroom",
3
- "version": "1.2.1",
3
+ "version": "1.2.2",
4
4
  "description": "Design with code, powered by your own component library",
5
5
  "bin": {
6
6
  "playroom": "bin/cli.cjs"
@@ -78,6 +78,7 @@ export const snippetsContainer = style([
78
78
  overflow: 'auto',
79
79
  paddingX: 'none',
80
80
  margin: 'none',
81
+ boxSizing: 'border-box',
81
82
  }),
82
83
  {
83
84
  listStyle: 'none',
@@ -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
- {snippetsByGroup.map(([group, groupSnippets]) => (
156
- <SnippetsGroup key={group} enableGroups={hasGroups} group={group}>
157
- {groupSnippets.map((snippet) => (
158
- <Command.Item
286
+ {isFiltering
287
+ ? filteredSnippets.map((snippet) => (
288
+ <SnippetItem
159
289
  key={snippet.id}
160
- value={snippet.id}
161
- onSelect={() => onSelect(snippet)}
162
- className={styles.snippet}
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
- <Tooltip
165
- delay={true}
166
- open={
167
- /**
168
- * Only show tooltip if likely to truncate, i.e. > 60 characters.
169
- */
170
- [snippet.name, snippet.description].join(' ').length < 60
171
- ? false
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>