@enigmax/primitives 0.2.0 → 0.3.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/package.json +2 -1
- package/recipes/input.css +32 -0
- package/recipes/input.css.tsx +28 -0
- package/recipes/input.tailwind.tsx +44 -0
- package/recipes/marquee.css +22 -0
- package/recipes/marquee.css.tsx +52 -0
- package/recipes/marquee.tailwind.tsx +61 -0
- package/recipes/search.tailwind.tsx +73 -0
- package/registry.json +74 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enigmax/primitives",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Headless interaction primitives: the behaviour, the timing and the accessibility of components like a draggable infinite marquee, with no styles of their own. Framework-agnostic core plus thin adapters.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"files": [
|
|
27
27
|
"dist",
|
|
28
28
|
"src",
|
|
29
|
+
"recipes",
|
|
29
30
|
"registry.json",
|
|
30
31
|
"README.md",
|
|
31
32
|
"LICENSE"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/* A starting point for the field, yours to edit. The primitive creates the action
|
|
2
|
+
buttons and publishes their state; it decides nothing about how they look. */
|
|
3
|
+
|
|
4
|
+
.enigma-field { display: grid; gap: 0.375rem; }
|
|
5
|
+
.enigma-field__label { font-size: 0.75rem; color: #a3a3a3; }
|
|
6
|
+
|
|
7
|
+
.enigma-field__box {
|
|
8
|
+
display: flex; align-items: center; gap: 0.375rem;
|
|
9
|
+
padding: 0 0.75rem;
|
|
10
|
+
background: #171717; border: 1px solid #404040; border-radius: 0.5rem;
|
|
11
|
+
}
|
|
12
|
+
.enigma-field__box:focus-within { border-color: #a3a3a3; }
|
|
13
|
+
|
|
14
|
+
.enigma-field__input {
|
|
15
|
+
flex: 1; min-width: 0; padding: 0.625rem 0;
|
|
16
|
+
font-size: 0.875rem; color: #f5f5f5;
|
|
17
|
+
background: transparent; border: 0; outline: none;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/* The engine's buttons. Styled through its hooks, never through a className. */
|
|
21
|
+
[data-enigma-input-actions] { display: inline-flex; gap: 0.125rem; }
|
|
22
|
+
[data-enigma-input-actions][data-position="start"] { order: -1; }
|
|
23
|
+
|
|
24
|
+
[data-enigma-input-action] {
|
|
25
|
+
display: grid; place-items: center;
|
|
26
|
+
width: 1.75rem; height: 1.75rem;
|
|
27
|
+
color: #a3a3a3; background: none; border: 0; border-radius: 0.375rem;
|
|
28
|
+
cursor: pointer; font-size: 0.9375rem;
|
|
29
|
+
}
|
|
30
|
+
[data-enigma-input-action]:hover { color: #f5f5f5; background: #262626; }
|
|
31
|
+
[data-enigma-input-action][aria-pressed="true"] { color: #fbbf24; }
|
|
32
|
+
[data-enigma-input-action]:disabled { opacity: 0.5; cursor: default; }
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import "./input.css";
|
|
2
|
+
import type { InputHTMLAttributes } from "react";
|
|
3
|
+
import { useInput } from "@enigmax/primitives/react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A styled field, yours to edit.
|
|
7
|
+
*
|
|
8
|
+
* The reveal toggle, its accessible name, its pressed state and the caret handling all
|
|
9
|
+
* come from the primitive. input.css beside this file is a starting point.
|
|
10
|
+
*/
|
|
11
|
+
export interface FieldProps extends InputHTMLAttributes<HTMLInputElement> {
|
|
12
|
+
label?: string;
|
|
13
|
+
/** Remove the automatic password reveal. */
|
|
14
|
+
reveal?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function Field({ label, reveal = true, className = "", ...props }: FieldProps) {
|
|
18
|
+
const { inputRef } = useInput({ reveal });
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<label className="enigma-field">
|
|
22
|
+
{label && <span className="enigma-field__label">{label}</span>}
|
|
23
|
+
<span className="enigma-field__box">
|
|
24
|
+
<input ref={inputRef} className={`enigma-field__input ${className}`.trim()} {...props} />
|
|
25
|
+
</span>
|
|
26
|
+
</label>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { InputHTMLAttributes } from "react";
|
|
2
|
+
import { useInput } from "@enigmax/primitives/react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A styled field, yours to edit.
|
|
6
|
+
*
|
|
7
|
+
* The reveal toggle, its accessible name, its pressed state and the caret handling all
|
|
8
|
+
* come from the primitive. Every class below is a suggestion.
|
|
9
|
+
*
|
|
10
|
+
* The action buttons are created by the engine, so they are styled through the
|
|
11
|
+
* data-* hooks rather than a className prop - hence the arbitrary-variant selectors.
|
|
12
|
+
*/
|
|
13
|
+
export interface FieldProps extends InputHTMLAttributes<HTMLInputElement> {
|
|
14
|
+
label?: string;
|
|
15
|
+
/** Remove the automatic password reveal. */
|
|
16
|
+
reveal?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function Field({ label, reveal = true, className = "", ...props }: FieldProps) {
|
|
20
|
+
const { inputRef } = useInput({ reveal });
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<label className="grid gap-1.5">
|
|
24
|
+
{label && <span className="text-xs text-neutral-400">{label}</span>}
|
|
25
|
+
<span
|
|
26
|
+
className="
|
|
27
|
+
flex items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-900 px-3
|
|
28
|
+
focus-within:border-neutral-400
|
|
29
|
+
[&_[data-enigma-input-actions]]:inline-flex
|
|
30
|
+
[&_[data-enigma-input-action]]:grid [&_[data-enigma-input-action]]:size-7 [&_[data-enigma-input-action]]:place-items-center
|
|
31
|
+
[&_[data-enigma-input-action]]:rounded-md [&_[data-enigma-input-action]]:text-neutral-400
|
|
32
|
+
[&_[data-enigma-input-action]:hover]:bg-neutral-800 [&_[data-enigma-input-action]:hover]:text-neutral-100
|
|
33
|
+
[&_[data-enigma-input-action][aria-pressed=true]]:text-amber-400
|
|
34
|
+
"
|
|
35
|
+
>
|
|
36
|
+
<input
|
|
37
|
+
ref={inputRef}
|
|
38
|
+
className={`min-w-0 flex-1 bg-transparent py-2.5 text-sm text-neutral-100 outline-none placeholder:text-neutral-500 ${className}`}
|
|
39
|
+
{...props}
|
|
40
|
+
/>
|
|
41
|
+
</span>
|
|
42
|
+
</label>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/* A starting point for the marquee, yours to edit. The primitive ships no styles;
|
|
2
|
+
it sets only what the behaviour needs (overflow, touch-action, user-select,
|
|
3
|
+
will-change, transform) and publishes its state as data-* attributes. */
|
|
4
|
+
|
|
5
|
+
.enigma-marquee { position: relative; width: 100%; cursor: grab; }
|
|
6
|
+
.enigma-marquee[data-grabbing] { cursor: grabbing; }
|
|
7
|
+
|
|
8
|
+
/* Fade both ends so items enter and leave instead of being cut. */
|
|
9
|
+
.enigma-marquee.is-faded {
|
|
10
|
+
-webkit-mask-image: linear-gradient(90deg, transparent, #000 6%, #000 94%, transparent);
|
|
11
|
+
mask-image: linear-gradient(90deg, transparent, #000 6%, #000 94%, transparent);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/* Never set `transform` or `translate` on the track: the engine owns it, and in
|
|
15
|
+
Tailwind v4 a translate-* utility composes with it rather than replacing it. */
|
|
16
|
+
.enigma-marquee__track { display: flex; }
|
|
17
|
+
.enigma-marquee__copy { display: flex; flex-shrink: 0; align-items: center; gap: 2.5rem; padding-right: 2.5rem; }
|
|
18
|
+
.enigma-marquee__item { flex-shrink: 0; }
|
|
19
|
+
|
|
20
|
+
/* State the engine publishes, for you to style. */
|
|
21
|
+
.enigma-marquee[data-hovering] .enigma-marquee__item { opacity: 1; }
|
|
22
|
+
.enigma-marquee[data-reduced-motion="true"] { /* autoplay is off; the drag still works */ }
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import "./marquee.css";
|
|
2
|
+
import type { ReactNode, Ref } from "react";
|
|
3
|
+
import { useMarquee } from "@enigmax/primitives/react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A styled marquee, yours to edit.
|
|
7
|
+
*
|
|
8
|
+
* The behaviour comes from the primitive, which ships no styles at all; marquee.css
|
|
9
|
+
* beside this file is a starting point you are meant to change.
|
|
10
|
+
*/
|
|
11
|
+
export interface MarqueeProps<T> {
|
|
12
|
+
items: T[];
|
|
13
|
+
/** Pixels per second. Never a duration - the row would speed up as items are added. */
|
|
14
|
+
speed?: number;
|
|
15
|
+
/** Speed multiplier while a mouse rests on the row. 0 pauses it. */
|
|
16
|
+
hoverScale?: number;
|
|
17
|
+
reverse?: boolean;
|
|
18
|
+
/** Fade both ends so items enter and leave instead of being cut. */
|
|
19
|
+
fade?: boolean;
|
|
20
|
+
className?: string;
|
|
21
|
+
children: (item: T, index: number) => ReactNode;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function Marquee<T>({
|
|
25
|
+
items,
|
|
26
|
+
speed = 70,
|
|
27
|
+
hoverScale = 1,
|
|
28
|
+
reverse = false,
|
|
29
|
+
fade = true,
|
|
30
|
+
className = "",
|
|
31
|
+
children
|
|
32
|
+
}: MarqueeProps<T>) {
|
|
33
|
+
const { laneRef, trackRef, copies, dragging } = useMarquee({ speed, hoverScale, reverse });
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div
|
|
37
|
+
ref={laneRef as Ref<HTMLDivElement>}
|
|
38
|
+
className={["enigma-marquee", fade ? "is-faded" : "", className].filter(Boolean).join(" ")}
|
|
39
|
+
data-grabbing={dragging ? "" : undefined}
|
|
40
|
+
>
|
|
41
|
+
<div ref={trackRef as Ref<HTMLDivElement>} className="enigma-marquee__track">
|
|
42
|
+
{Array.from({ length: copies }, (_, copy) => (
|
|
43
|
+
<div key={copy} aria-hidden={copy > 0} className="enigma-marquee__copy">
|
|
44
|
+
{items.map((item, index) => (
|
|
45
|
+
<div key={index} className="enigma-marquee__item">{children(item, index)}</div>
|
|
46
|
+
))}
|
|
47
|
+
</div>
|
|
48
|
+
))}
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ReactNode, Ref } from "react";
|
|
2
|
+
import { useMarquee } from "@enigmax/primitives/react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A styled marquee, yours to edit.
|
|
6
|
+
*
|
|
7
|
+
* The behaviour comes from the primitive, which ships no styles at all; every class
|
|
8
|
+
* below is a suggestion you are meant to change.
|
|
9
|
+
*
|
|
10
|
+
* TAILWIND v4 WARNING: never put a `translate-*` utility on the track. v4 writes those
|
|
11
|
+
* to the CSS `translate` property, which COMPOSES with `transform` rather than replacing
|
|
12
|
+
* it, so the class and the engine's transform add up and the row drifts. Anything you
|
|
13
|
+
* need to offset goes on the lane or on an inner element, never on the moved one.
|
|
14
|
+
*/
|
|
15
|
+
export interface MarqueeProps<T> {
|
|
16
|
+
items: T[];
|
|
17
|
+
/** Pixels per second. Never a duration - the row would speed up as items are added. */
|
|
18
|
+
speed?: number;
|
|
19
|
+
/** Speed multiplier while a mouse rests on the row. 0 pauses it. */
|
|
20
|
+
hoverScale?: number;
|
|
21
|
+
reverse?: boolean;
|
|
22
|
+
/** Fade both ends so items enter and leave instead of being cut. */
|
|
23
|
+
fade?: boolean;
|
|
24
|
+
className?: string;
|
|
25
|
+
children: (item: T, index: number) => ReactNode;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function Marquee<T>({
|
|
29
|
+
items,
|
|
30
|
+
speed = 70,
|
|
31
|
+
hoverScale = 1,
|
|
32
|
+
reverse = false,
|
|
33
|
+
fade = true,
|
|
34
|
+
className = "",
|
|
35
|
+
children
|
|
36
|
+
}: MarqueeProps<T>) {
|
|
37
|
+
const { laneRef, trackRef, copies, dragging } = useMarquee({ speed, hoverScale, reverse });
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div
|
|
41
|
+
ref={laneRef as Ref<HTMLDivElement>}
|
|
42
|
+
className={[
|
|
43
|
+
"relative w-full",
|
|
44
|
+
dragging ? "cursor-grabbing" : "cursor-grab",
|
|
45
|
+
fade ? "[mask-image:linear-gradient(90deg,transparent,#000_6%,#000_94%,transparent)]" : "",
|
|
46
|
+
className
|
|
47
|
+
].filter(Boolean).join(" ")}
|
|
48
|
+
>
|
|
49
|
+
{/* No translate-* utility here. See the note at the top of this file. */}
|
|
50
|
+
<div ref={trackRef as Ref<HTMLDivElement>} className="flex">
|
|
51
|
+
{Array.from({ length: copies }, (_, copy) => (
|
|
52
|
+
<div key={copy} aria-hidden={copy > 0} className="flex shrink-0 items-center gap-10 pr-10">
|
|
53
|
+
{items.map((item, index) => (
|
|
54
|
+
<div key={index} className="shrink-0">{children(item, index)}</div>
|
|
55
|
+
))}
|
|
56
|
+
</div>
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import Fuse from "fuse.js";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { useSearch } from "@enigmax/primitives/react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A styled search field with its result list, yours to edit.
|
|
7
|
+
*
|
|
8
|
+
* Debouncing, ranking, cancellation and the Escape-to-clear all come from the primitive.
|
|
9
|
+
* Drop the Fuse import and the `fuse` prop to fall back to the built-in
|
|
10
|
+
* accent-insensitive substring matcher and no dependency at all.
|
|
11
|
+
*/
|
|
12
|
+
export interface SearchProps<T> {
|
|
13
|
+
items: T[];
|
|
14
|
+
/** Fields to search. Dotted paths work: "author.name". */
|
|
15
|
+
keys: string[];
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
/** Fuzzy matching. Omit for the zero-dependency substring matcher. */
|
|
18
|
+
fuzzy?: boolean;
|
|
19
|
+
emptyMessage?: string;
|
|
20
|
+
children: (item: T, score: number) => ReactNode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function Search<T>({
|
|
24
|
+
items,
|
|
25
|
+
keys,
|
|
26
|
+
placeholder = "Search",
|
|
27
|
+
fuzzy = true,
|
|
28
|
+
emptyMessage = "No matches",
|
|
29
|
+
children
|
|
30
|
+
}: SearchProps<T>) {
|
|
31
|
+
const { inputRef, results, query } = useSearch<T>({
|
|
32
|
+
items,
|
|
33
|
+
keys,
|
|
34
|
+
fuse: fuzzy ? (Fuse as never) : undefined,
|
|
35
|
+
debounce: 120
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className="grid gap-2.5">
|
|
40
|
+
<input
|
|
41
|
+
ref={inputRef}
|
|
42
|
+
type="search"
|
|
43
|
+
aria-label={placeholder}
|
|
44
|
+
placeholder={placeholder}
|
|
45
|
+
className="
|
|
46
|
+
rounded-lg border border-neutral-700 bg-neutral-900 px-3 py-2.5
|
|
47
|
+
text-sm text-neutral-100 outline-none
|
|
48
|
+
placeholder:text-neutral-500 focus:border-neutral-400
|
|
49
|
+
"
|
|
50
|
+
/>
|
|
51
|
+
|
|
52
|
+
{query && (
|
|
53
|
+
<p className="font-mono text-[11px] text-neutral-500">
|
|
54
|
+
{results.length} result{results.length === 1 ? "" : "s"} for "{query}"
|
|
55
|
+
</p>
|
|
56
|
+
)}
|
|
57
|
+
|
|
58
|
+
<ul className="grid list-none gap-1.5 p-0">
|
|
59
|
+
{results.map((result, index) => (
|
|
60
|
+
<li
|
|
61
|
+
key={index}
|
|
62
|
+
className="rounded-lg border border-neutral-800 bg-neutral-900 px-3 py-2 text-sm text-neutral-100"
|
|
63
|
+
>
|
|
64
|
+
{children(result.item, result.score)}
|
|
65
|
+
</li>
|
|
66
|
+
))}
|
|
67
|
+
{query && results.length === 0 && (
|
|
68
|
+
<li className="text-sm text-neutral-500">{emptyMessage}</li>
|
|
69
|
+
)}
|
|
70
|
+
</ul>
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
package/registry.json
CHANGED
|
@@ -47,6 +47,32 @@
|
|
|
47
47
|
"rewrite": {
|
|
48
48
|
"@/core/marquee": "./marquee"
|
|
49
49
|
}
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"path": "recipes/marquee.tailwind.tsx",
|
|
53
|
+
"dest": "Marquee.tsx",
|
|
54
|
+
"targets": [
|
|
55
|
+
"react"
|
|
56
|
+
],
|
|
57
|
+
"style": "tailwind"
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"path": "recipes/marquee.css.tsx",
|
|
61
|
+
"dest": "Marquee.tsx",
|
|
62
|
+
"targets": [
|
|
63
|
+
"react"
|
|
64
|
+
],
|
|
65
|
+
"style": "css"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"path": "recipes/marquee.css",
|
|
69
|
+
"dest": "marquee.css",
|
|
70
|
+
"targets": [
|
|
71
|
+
"react",
|
|
72
|
+
"vanilla",
|
|
73
|
+
"astro"
|
|
74
|
+
],
|
|
75
|
+
"style": "css"
|
|
50
76
|
}
|
|
51
77
|
],
|
|
52
78
|
"styles": false,
|
|
@@ -58,7 +84,11 @@
|
|
|
58
84
|
"[data-enigma-marquee-track]",
|
|
59
85
|
"[data-enigma-marquee-copy]"
|
|
60
86
|
],
|
|
61
|
-
"docs": "docs/notes/primitives.md#marquee"
|
|
87
|
+
"docs": "docs/notes/primitives.md#marquee",
|
|
88
|
+
"recipes": [
|
|
89
|
+
"tailwind",
|
|
90
|
+
"css"
|
|
91
|
+
]
|
|
62
92
|
},
|
|
63
93
|
{
|
|
64
94
|
"name": "input",
|
|
@@ -104,6 +134,32 @@
|
|
|
104
134
|
"rewrite": {
|
|
105
135
|
"@/core/input": "./input"
|
|
106
136
|
}
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
"path": "recipes/input.tailwind.tsx",
|
|
140
|
+
"dest": "Field.tsx",
|
|
141
|
+
"targets": [
|
|
142
|
+
"react"
|
|
143
|
+
],
|
|
144
|
+
"style": "tailwind"
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
"path": "recipes/input.css.tsx",
|
|
148
|
+
"dest": "Field.tsx",
|
|
149
|
+
"targets": [
|
|
150
|
+
"react"
|
|
151
|
+
],
|
|
152
|
+
"style": "css"
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
"path": "recipes/input.css",
|
|
156
|
+
"dest": "input.css",
|
|
157
|
+
"targets": [
|
|
158
|
+
"react",
|
|
159
|
+
"vanilla",
|
|
160
|
+
"astro"
|
|
161
|
+
],
|
|
162
|
+
"style": "css"
|
|
107
163
|
}
|
|
108
164
|
],
|
|
109
165
|
"styles": false,
|
|
@@ -115,7 +171,11 @@
|
|
|
115
171
|
"[data-enigma-input-action]",
|
|
116
172
|
"[data-enigma-input-action=reveal]"
|
|
117
173
|
],
|
|
118
|
-
"docs": "docs/notes/primitives.md#input"
|
|
174
|
+
"docs": "docs/notes/primitives.md#input",
|
|
175
|
+
"recipes": [
|
|
176
|
+
"tailwind",
|
|
177
|
+
"css"
|
|
178
|
+
]
|
|
119
179
|
},
|
|
120
180
|
{
|
|
121
181
|
"name": "search",
|
|
@@ -161,6 +221,14 @@
|
|
|
161
221
|
"rewrite": {
|
|
162
222
|
"@/core/search": "./search"
|
|
163
223
|
}
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
"path": "recipes/search.tailwind.tsx",
|
|
227
|
+
"dest": "Search.tsx",
|
|
228
|
+
"targets": [
|
|
229
|
+
"react"
|
|
230
|
+
],
|
|
231
|
+
"style": "tailwind"
|
|
164
232
|
}
|
|
165
233
|
],
|
|
166
234
|
"styles": false,
|
|
@@ -170,7 +238,10 @@
|
|
|
170
238
|
"optionalDependencies": {
|
|
171
239
|
"fuse.js": "Fuzzy matching. Omit it and a built-in substring matcher is used."
|
|
172
240
|
},
|
|
173
|
-
"docs": "docs/notes/primitives.md#search"
|
|
241
|
+
"docs": "docs/notes/primitives.md#search",
|
|
242
|
+
"recipes": [
|
|
243
|
+
"tailwind"
|
|
244
|
+
]
|
|
174
245
|
}
|
|
175
246
|
]
|
|
176
247
|
}
|