Skip to content

Commit 39c5bd3

Browse files
authored
Add @headlessui/tailwindcss plugin (#1487)
* add `@headlessui/tailwindcss` plugin * expose `data-headlessui-state="..."` data attribute All components that expose boolean props in their render prop / v-slot will receive a `data-headlessui-state="..."` attribute. If it exposes boolean values but all are false, then there will be an empty `data-headlessui-state=""`. If the current component is rendering a `Fragment` then we don't expose those attributes. * use tailwindcss in `playground-react` and `playground-vue` We were using the CDN, but now that we have the `@headlessui/tailwindcss` plugin, it's a bit easier to configure it natively and import the plugin. * ensure to build the `@headlessui/tailwindcss` package before starting the playground * refactor `listbox` example to use the @headlessui/tailwindcss plugin * update changelog * bump Tailwind CSS to latest insiders version * correctly generate types * type `tailwind.config.js` files for playgrounds * add todo for when `:has()` is available
1 parent ebf19ca commit 39c5bd3

File tree

21 files changed

+557
-81
lines changed

21 files changed

+557
-81
lines changed

CHANGELOG.md

+14-6
Original file line numberDiff line numberDiff line change
@@ -5,29 +5,37 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8-
## [Unreleased - @headlessui/vue]
8+
## [Unreleased - @headlessui/react]
99

1010
### Fixed
1111

12-
- Allow to override the `type` on the `ComboboxInput` ([#1476](https://github.com/tailwindlabs/headlessui/pull/1476))
13-
- Ensure the the `<PopoverPanel focus>` closes correctly ([#1477](https://github.com/tailwindlabs/headlessui/pull/1477))
12+
- Allow to override the `type` on the `Combobox.Input` ([#1476](https://github.com/tailwindlabs/headlessui/pull/1476))
13+
- Ensure the the `<Popover.Panel focus>` closes correctly ([#1477](https://github.com/tailwindlabs/headlessui/pull/1477))
1414
- Only render the `FocusSentinel` if required in the `Tabs` component ([#1493](https://github.com/tailwindlabs/headlessui/pull/1493))
1515

1616
### Added
1717

1818
- Add `by` prop for `Listbox`, `Combobox` and `RadioGroup` ([#1482](https://github.com/tailwindlabs/headlessui/pull/1482))
19+
- Add `@headlessui/tailwindcss` plugin ([#1487](https://github.com/tailwindlabs/headlessui/pull/1487))
1920

20-
## [Unreleased - @headlessui/react]
21+
## [Unreleased - @headlessui/vue]
2122

2223
### Fixed
2324

24-
- Allow to override the `type` on the `Combobox.Input` ([#1476](https://github.com/tailwindlabs/headlessui/pull/1476))
25-
- Ensure the the `<Popover.Panel focus>` closes correctly ([#1477](https://github.com/tailwindlabs/headlessui/pull/1477))
25+
- Allow to override the `type` on the `ComboboxInput` ([#1476](https://github.com/tailwindlabs/headlessui/pull/1476))
26+
- Ensure the the `<PopoverPanel focus>` closes correctly ([#1477](https://github.com/tailwindlabs/headlessui/pull/1477))
2627
- Only render the `FocusSentinel` if required in the `Tabs` component ([#1493](https://github.com/tailwindlabs/headlessui/pull/1493))
2728

2829
### Added
2930

3031
- Add `by` prop for `Listbox`, `Combobox` and `RadioGroup` ([#1482](https://github.com/tailwindlabs/headlessui/pull/1482))
32+
- Add `@headlessui/tailwindcss` plugin ([#1487](https://github.com/tailwindlabs/headlessui/pull/1487))
33+
34+
## [Unreleased - @headlessui/tailwindcss]
35+
36+
### Added
37+
38+
- Add `@headlessui/tailwindcss` plugin ([#1487](https://github.com/tailwindlabs/headlessui/pull/1487))
3139

3240
## [@headlessui/vue@v1.6.2] - 2022-05-19
3341

packages/@headlessui-react/src/utils/render.ts

+23-1
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,22 @@ function _render<TTag extends ElementType, TSlot>(
125125
;(rest as any).className = rest.className(slot)
126126
}
127127

128+
let dataAttributes: Record<string, string> = {}
129+
if (slot) {
130+
let exposeState = false
131+
let states = []
132+
for (let [k, v] of Object.entries(slot)) {
133+
if (typeof v === 'boolean') {
134+
exposeState = true
135+
}
136+
if (v === true) {
137+
states.push(k)
138+
}
139+
}
140+
141+
if (exposeState) dataAttributes[`data-headlessui-state`] = states.join(' ')
142+
}
143+
128144
if (Component === Fragment) {
129145
if (Object.keys(compact(rest)).length > 0) {
130146
if (
@@ -158,6 +174,7 @@ function _render<TTag extends ElementType, TSlot>(
158174
{},
159175
// Filter out undefined values so that they don't override the existing values
160176
mergeProps(resolvedChildren.props, compact(omit(rest, ['ref']))),
177+
dataAttributes,
161178
refRelatedProps
162179
)
163180
)
@@ -166,7 +183,12 @@ function _render<TTag extends ElementType, TSlot>(
166183

167184
return createElement(
168185
Component,
169-
Object.assign({}, omit(rest, ['ref']), Component !== Fragment && refRelatedProps),
186+
Object.assign(
187+
{},
188+
omit(rest, ['ref']),
189+
Component !== Fragment && refRelatedProps,
190+
Component !== Fragment && dataAttributes
191+
),
170192
resolvedChildren
171193
)
172194
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
'use strict'
2+
3+
let plugin =
4+
process.env.NODE_ENV === 'production'
5+
? require('./headlessui.prod.cjs')
6+
: require('./headlessui.dev.cjs')
7+
8+
module.exports = (plugin.__esModule ? plugin : { default: plugin }).default
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{
2+
"name": "@headlessui/tailwindcss",
3+
"version": "0.0.0",
4+
"description": "A complementary Tailwind CSS plugin",
5+
"main": "dist/index.cjs",
6+
"types": "dist/index.d.ts",
7+
"license": "MIT",
8+
"files": [
9+
"README.md",
10+
"dist"
11+
],
12+
"exports": {
13+
"require": "./dist/index.cjs"
14+
},
15+
"sideEffects": false,
16+
"engines": {
17+
"node": ">=10"
18+
},
19+
"repository": {
20+
"type": "git",
21+
"url": "git+https://github.com/tailwindlabs/headlessui.git",
22+
"directory": "packages/@headlessui-tailwindcss"
23+
},
24+
"publishConfig": {
25+
"access": "public"
26+
},
27+
"scripts": {
28+
"prepublishOnly": "npm run build",
29+
"build": "../../scripts/build.sh --external:tailwindcss && node ./scripts/fix-types.js",
30+
"watch": "../../scripts/watch.sh --external:tailwindcss",
31+
"test": "../../scripts/test.sh",
32+
"lint": "../../scripts/lint.sh",
33+
"clean": "rimraf ./dist"
34+
},
35+
"peerDependencies": {
36+
"tailwindcss": "^3.0"
37+
},
38+
"devDependencies": {
39+
"esbuild": "^0.11.18"
40+
},
41+
"dependencies": {
42+
"tailwindcss": "^0.0.0-insiders.83b4811"
43+
}
44+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
let fs = require('fs/promises')
2+
let path = require('path')
3+
4+
/**
5+
* Everything in the Headless UI codebase is written in TypeScript, however the
6+
* `@headlessui/tailwindcss` plugin has to compile to a CommonJs file. This works great, however
7+
* the types that were generated by tsc use `export default _default` instead of `export =
8+
* _default` even if we use `module: CommonJs` in the `tsconfig.json`
9+
*
10+
* Don't want to spend too much time on this problem, so doing this little hack to change the
11+
* exported type. This allows us to use the `@headlessui/tailwindcss` plugin and have types in a
12+
* CommonJs environment.
13+
**/
14+
15+
let types = path.resolve(__dirname, '..', 'dist', 'index.d.ts')
16+
17+
async function run() {
18+
let contents = await fs.readFile(types, 'utf8')
19+
contents = contents.replace('export default', 'export =')
20+
await fs.writeFile(types, contents, 'utf8')
21+
}
22+
23+
run()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import plugin from 'tailwindcss/plugin'
2+
3+
interface Options {
4+
/**
5+
* The prefix used for the variants. This defaults to `ui`.
6+
*
7+
* Usage example:
8+
* ```html
9+
* <div class="ui-open:underline"></div>
10+
* ```
11+
**/
12+
prefix?: string
13+
}
14+
15+
export default plugin.withOptions<Options>(({ prefix = 'ui' } = {}) => {
16+
return ({ addVariant }) => {
17+
for (let state of ['open', 'checked', 'selected', 'active', 'disabled']) {
18+
// TODO: Once `:has()` is properly supported, then we can switch to this version:
19+
// addVariant(`${prefix}-${state}`, [
20+
// `&[data-headlessui-state~="${state}"]`,
21+
// `:where([data-headlessui-state~="${state}"]):not(:has([data-headlessui-state])) &`,
22+
// ])
23+
24+
// But for now, this will do:
25+
addVariant(`${prefix}-${state}`, [
26+
`&[data-headlessui-state~="${state}"]`,
27+
`:where([data-headlessui-state~="${state}"]) &`,
28+
])
29+
30+
addVariant(`${prefix}-not-${state}`, [
31+
`&[data-headlessui-state]:not([data-headlessui-state~="${state}"])`,
32+
`:where([data-headlessui-state]:not([data-headlessui-state~="${state}"]) &:not([data-headlessui-state]))`,
33+
])
34+
}
35+
}
36+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"include": ["src"],
3+
"compilerOptions": {
4+
"module": "ESNext",
5+
"lib": [],
6+
"importHelpers": true,
7+
"declaration": true,
8+
"sourceMap": true,
9+
"rootDir": "./src",
10+
"strict": true,
11+
"noUnusedLocals": true,
12+
"noUnusedParameters": true,
13+
"noImplicitReturns": true,
14+
"noFallthroughCasesInSwitch": true,
15+
"downlevelIteration": true,
16+
"moduleResolution": "node",
17+
"baseUrl": "./",
18+
"paths": {
19+
"*": ["src/*", "node_modules/*"]
20+
},
21+
"jsx": "preserve",
22+
"esModuleInterop": true,
23+
"target": "ESNext",
24+
"allowJs": true,
25+
"skipLibCheck": true,
26+
"forceConsistentCasingInFileNames": true,
27+
"resolveJsonModule": true,
28+
"isolatedModules": false
29+
},
30+
"exclude": ["node_modules", "**/*.test.tsx?"]
31+
}

packages/@headlessui-vue/src/utils/render.ts

+21-2
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,22 @@ function _render({
8585

8686
let children = slots.default?.(slot)
8787

88+
let dataAttributes: Record<string, string> = {}
89+
if (slot) {
90+
let exposeState = false
91+
let states = []
92+
for (let [k, v] of Object.entries(slot)) {
93+
if (typeof v === 'boolean') {
94+
exposeState = true
95+
}
96+
if (v === true) {
97+
states.push(k)
98+
}
99+
}
100+
101+
if (exposeState) dataAttributes[`data-headlessui-state`] = states.join(' ')
102+
}
103+
88104
if (as === 'template') {
89105
if (Object.keys(incomingProps).length > 0 || Object.keys(attrs).length > 0) {
90106
let [firstChild, ...other] = children ?? []
@@ -112,7 +128,10 @@ function _render({
112128
)
113129
}
114130

115-
return cloneVNode(firstChild, incomingProps as Record<string, any>)
131+
return cloneVNode(
132+
firstChild,
133+
Object.assign({}, incomingProps as Record<string, any>, dataAttributes)
134+
)
116135
}
117136

118137
if (Array.isArray(children) && children.length === 1) {
@@ -122,7 +141,7 @@ function _render({
122141
return children
123142
}
124143

125-
return h(as, incomingProps, children)
144+
return h(as, Object.assign({}, incomingProps, dataAttributes), children)
126145
}
127146

128147
export function compact<T extends Record<any, any>>(object: T) {

packages/playground-react/package.json

+10-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
"private": true,
44
"version": "0.0.0",
55
"scripts": {
6-
"prebuild": "yarn workspace @headlessui/react build",
6+
"prebuild": "yarn workspace @headlessui/react build && yarn workspace @headlessui/tailwindcss build",
7+
"predev": "yarn workspace @headlessui/react build && yarn workspace @headlessui/tailwindcss build",
8+
"dev:tailwindcss": "yarn workspace @headlessui/tailwindcss watch",
79
"dev:headlessui": "yarn workspace @headlessui/react watch",
810
"dev:next": "next dev",
911
"dev": "npm-run-all -p dev:*",
@@ -13,11 +15,17 @@
1315
},
1416
"dependencies": {
1517
"@headlessui/react": "*",
18+
"@headlessui/tailwindcss": "*",
1619
"@popperjs/core": "^2.6.0",
20+
"@tailwindcss/forms": "^0.5.2",
21+
"@tailwindcss/typography": "^0.5.2",
22+
"autoprefixer": "^10.4.7",
1723
"framer-motion": "^6.0.0",
1824
"next": "^12.1.4",
25+
"postcss": "^8.4.14",
1926
"react": "^18.0.0",
2027
"react-dom": "^18.0.0",
21-
"react-flatpickr": "^3.10.9"
28+
"react-flatpickr": "^3.10.9",
29+
"tailwindcss": "^0.0.0-insiders.83b4811"
2230
}
2331
}

packages/playground-react/pages/_app.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import React, { useState, useEffect } from 'react'
22
import Link from 'next/link'
33
import Head from 'next/head'
44

5+
import 'tailwindcss/tailwind.css'
6+
57
function disposables() {
68
let disposables: Function[] = []
79

packages/playground-react/pages/_document.tsx

-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ export default function Document() {
88
<meta name="viewport" content="width=device-width, initial-scale=1" />
99

1010
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
11-
<script src="https://cdn.tailwindcss.com?plugins=forms,typography"></script>
1211

1312
<link
1413
rel="icon"

packages/playground-react/pages/listbox/listbox-with-pure-tailwind.tsx

+13-34
Original file line numberDiff line numberDiff line change
@@ -67,41 +67,20 @@ export default function Home() {
6767
<Listbox.Option
6868
key={name}
6969
value={name}
70-
className={({ active }) => {
71-
return classNames(
72-
'relative cursor-default select-none py-2 pl-3 pr-9 focus:outline-none',
73-
active ? 'bg-indigo-600 text-white' : 'text-gray-900'
74-
)
75-
}}
70+
className="ui-active:bg-indigo-600 ui-active:text-white ui-not-active:text-gray-900 relative cursor-default select-none py-2 pl-3 pr-9 focus:outline-none"
7671
>
77-
{({ active, selected }) => (
78-
<>
79-
<span
80-
className={classNames(
81-
'block truncate',
82-
selected ? 'font-semibold' : 'font-normal'
83-
)}
84-
>
85-
{name}
86-
</span>
87-
{selected && (
88-
<span
89-
className={classNames(
90-
'absolute inset-y-0 right-0 flex items-center pr-4',
91-
active ? 'text-white' : 'text-indigo-600'
92-
)}
93-
>
94-
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
95-
<path
96-
fillRule="evenodd"
97-
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
98-
clipRule="evenodd"
99-
/>
100-
</svg>
101-
</span>
102-
)}
103-
</>
104-
)}
72+
<span className="ui-selected:font-semibold ui-not-selected:font-normal block truncate">
73+
{name}
74+
</span>
75+
<span className="ui-not-selected:hidden ui-selected:flex ui-active:text-white ui-not-active:text-indigo-600 absolute inset-y-0 right-0 items-center pr-4">
76+
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
77+
<path
78+
fillRule="evenodd"
79+
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
80+
clipRule="evenodd"
81+
/>
82+
</svg>
83+
</span>
10584
</Listbox.Option>
10685
))}
10786
</Listbox.Options>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
module.exports = {
2+
plugins: {
3+
tailwindcss: {},
4+
autoprefixer: {},
5+
},
6+
}

0 commit comments

Comments
 (0)