Tailwind Plus 的原生 JavaScript 支持

Philipp Spiess
Adam Wathan
Tailwind Plus 元素

Tailwind Plus 中有许多 UI 模块需要 JavaScript 才能真正发挥作用,比如对话框、下拉菜单、命令面板等等。如果您不是 React 或 Vue 用户,以前使用这些 UI 模块就意味着必须自己编写所有那些复杂的 JavaScript 代码。

今天这一切终于改变了——Tailwind Plus 中的每个 UI 模块现在都完全可用、无障碍且具备交互功能,包括纯 HTML 示例。

现在,您可以在任何项目中使用任意 下拉菜单命令面板对话框抽屉 等,无需任何 JavaScript 框架。


无需框架

为实现这一点,我们构建了 @tailwindplus/elements —— 这是一个专门为 Tailwind Plus 用户发布的库。

Elements 是一组无界面(headless)的 自定义元素,封装了构建交互式自定义 UI 需要的所有复杂行为,仅用 HTML 即可实现,样式可以用工具类或自定义 CSS 任意调整。

这些自定义元素不依赖特定的 JavaScript 框架,能在任何能使用 <script> 标签的地方运行:

index.html
<script src="https://cdn.jsdelivr.net/npm/@tailwindplus/elements@1" type="module"></script>

下面是用 Elements 构建一个 自定义下拉菜单 的示例:

<el-dropdown class="relative inline-block text-left">  <button class="inline-flex w-full justify-center gap-x-1.5 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs ring-1 ring-gray-300 ring-inset hover:bg-gray-50">    Options    <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="-mr-1 size-5 text-gray-400">      <path d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" fill-rule="evenodd" />    </svg>  </button>  <el-menu anchor="bottom end" popover class="w-56 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black/5 transition transition-discrete [--anchor-gap:--spacing(2)] focus:outline-hidden data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in">    <div class="py-1">      <a href="#" class="block px-4 py-2 text-sm text-gray-700 focus:bg-gray-100 focus:text-gray-900 focus:outline-hidden">账号设置</a>      <a href="#" class="block px-4 py-2 text-sm text-gray-700 focus:bg-gray-100 focus:text-gray-900 focus:outline-hidden">支持</a>      <a href="#" class="block px-4 py-2 text-sm text-gray-700 focus:bg-gray-100 focus:text-gray-900 focus:outline-hidden">许可证</a>      <form action="#" method="POST">        <button type="submit" class="block w-full px-4 py-2 text-left text-sm text-gray-700 focus:bg-gray-100 focus:text-gray-900 focus:outline-hidden">登出</button>      </form>    </div>  </el-menu></el-dropdown>

下面是创建一个 自定义选择框 的示例:

<label for="select" class="block text-sm/6 font-medium text-gray-900">分配给</label><el-select id="select" name="selected" value="4" class="mt-2 block">  <button type="button" class="grid w-full cursor-default grid-cols-1 ...">    <el-selectedcontent></el-selectedcontent>    <svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" class="col-start-1 row-start-1 ...">      <!-- ... -->    </svg>  </button>  <el-options anchor="bottom start" popover class="max-h-60 w-(--button-width) [--anchor-gap:--spacing(1)] ...">    <el-option value="1" class="group/option relative block focus:bg-indigo-600 ...">      <div class="flex items-center">        <span aria-hidden="true" class="inline-block size-2 shrink-0 ..."></span>        <span class="ml-3 block group-aria-selected/option:font-semibold ...">          Wade Cooper          <span class="sr-only"> 在线</span>        </span>      </div>      <span class="group-not-aria-selected/option:hidden group-focus/option:text-white in-[el-selectedcontent]:hidden ...">        <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="size-5">            <!-- ... -->        </svg>      </span>    </el-option>    <!-- ... -->  </el-options></el-select>

看到这些像 <el-select><el-options> 这样的自定义 HTML 元素了吗?它们就是实现所有功能的秘诀,包括自动 ARIA 属性管理、焦点处理、键盘支持等等。

您甚至可以用 Elements 构建一个和 命令面板 一样复杂的功能,而无需自己写任何 JavaScript:

<button command="show-modal" commandfor="dialog" class="rounded-md bg-white/80 px-2.5 py-1.5 text-sm font-semibold text-gray-900 hover:bg-white/90">  打开命令面板</button><el-dialog>  <dialog id="dialog" class="backdrop:bg-transparent">    <el-dialog-backdrop class="fixed inset-0 bg-gray-500/25 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in"></div>    <div tabindex="0" class="fixed inset-0 w-screen overflow-y-auto p-4 focus:outline-none sm:p-6 md:p-20">      <el-dialog-panel class="mx-auto block max-w-3xl transform overflow-hidden rounded-xl bg-white shadow-2xl ring-1 ring-black/5 transition-all data-closed:scale-95 data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in">        <el-command-palette class="divide-y divide-gray-100">          <div class="grid grid-cols-1">            <input type="text" autofocus placeholder="搜索..." class="col-start-1 row-start-1 h-12 w-full pr-4 pl-11 text-base text-gray-900 outline-hidden placeholder:text-gray-400 sm:text-sm" />            <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 ml-4 size-5 self-center text-gray-400">              <path d="M9 3.5a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11ZM2 9a7 7 0 1 1 12.452 4.391l3.328 3.329a.75.75 0 1 1-1.06 1.06l-3.329-3.328A7 7 0 0 1 2 9Z" clip-rule="evenodd" fill-rule="evenodd" />            </svg>          </div>          <div class="flex transform-gpu divide-x divide-gray-100">            <div class="max-h-96 min-w-0 flex-auto scroll-py-4 overflow-y-auto px-6 py-4">              <el-command-list class="-mx-2 block text-sm text-gray-700">                <el-defaults>                  <h2 class="mx-2 mt-2 mb-4 text-xs font-semibold text-gray-500">最近搜索</h2>                  <div class="text-sm text-gray-700">                    <a id="person-suggestion-6" href="#" class="group flex cursor-default items-center rounded-md p-2 select-none focus:outline-hidden aria-selected:bg-gray-100 aria-selected:text-gray-900">                      <img src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80" alt="" class="size-6 flex-none rounded-full" />                      <span class="ml-3 flex-auto truncate">Tom Cook</span>                      <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="ml-3 hidden size-5 flex-none text-gray-400 group-aria-selected:block">                        <path d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" fill-rule="evenodd" />                      </svg>                    </a>                   <!-- ... -->                  </div>                </el-defaults>                <el-command-group hidden class="sm:h-96">                  <a id="person-1" href="#" hidden class="group flex cursor-default items-center rounded-md p-2 select-none focus:outline-hidden aria-selected:bg-gray-100 aria-selected:text-gray-900">                    <img src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80" alt="" class="size-6 flex-none rounded-full" />                    <span class="ml-3 flex-auto truncate">Leslie Alexander</span>                    <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="ml-3 hidden size-5 flex-none text-gray-400 group-aria-selected:block">                      <path d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" fill-rule="evenodd" />                    </svg>                  </a>                  <!-- ... -->                </el-command-group>              </el-command-list>              <el-no-results hidden class="block px-6 py-14 text-center text-sm sm:px-14">                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true" class="mx-auto size-6 text-gray-400">                  <path d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" stroke-linecap="round" stroke-linejoin="round" />                </svg>                <p class="mt-4 font-semibold text-gray-900">未找到用户</p>                <p class="mt-2 text-gray-500">未找到匹配的内容,请重试。</p>              </el-no-results>            </div>            <el-command-preview for="person-1" hidden class="h-96 w-1/2 flex-none flex-col divide-y divide-gray-100 overflow-y-auto sm:flex">              <div class="flex-none p-6 text-center">                <img src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80" alt="" class="mx-auto size-16 rounded-full" />                <h2 class="mt-3 font-semibold text-gray-900">Leslie Alexander</h2>                <p class="text-sm/6 text-gray-500">联合创始人 / CEO</p>              </div>              <div class="flex flex-auto flex-col justify-between p-6">                <dl class="grid grid-cols-1 gap-x-6 gap-y-3 text-sm text-gray-700">                  <dt class="col-end-1 font-semibold text-gray-900">电话</dt>                  <dd>1-493-747-9031</dd>                  <dt class="col-end-1 font-semibold text-gray-900">网址</dt>                  <dd class="truncate"><a href="https://example.com" class="text-indigo-600 underline">https://example.com</a></dd>                  <dt class="col-end-1 font-semibold text-gray-900">邮箱</dt>                  <dd class="truncate"><a href="mailto:lesliealexander@example.com" class="text-indigo-600 underline">lesliealexander@example.com</a></dd>                </dl>                <button type="button" class="mt-6 w-full rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">发送消息</button>              </div>            </el-command-preview>            <!-- ... -->          </div>        </el-command-palette>      </el-dialog-panel>    </div>  </dialog></el-dialog>

为了支持 Tailwind Plus 中所有 UI 模块,这个 Elements 首个版本中包含了以下基础组件:

如果您是 Tailwind Plus 用户,请访问全新的 Elements 文档 了解详细原理并查看示例。


利用现代 Web 技术

我们大量依赖现代平台特性,确保 Elements 轻量且尽可能原生高效。

  • 使用 自定义元素 作为跨平台组件抽象。
  • 利用 popover 属性 管理弹出层和弹出框,配合 beforetoggle 控制过渡动画。
  • 使用原生的 <dialog> 元素实现焦点锁定和顶层渲染。
  • 通过 Invoker 命令 声明式管理交互元素,比如切换自定义折叠内容。
  • 利用 ElementInternals 实现自定义表单控件与原生表单控件一样的行为。

我们还打包了这些特性所需的所有 polyfill,确保 Elements 能在所有 Tailwind CSS v4.0 支持的浏览器中正常运行。随着现代平台特性的普及,Elements 会变得更加轻量。


适用于所有环境的组件

HTML 是所有 Web 框架间的最低公共分母,Elements 让所有仅用 HTML 的 Tailwind Plus UI 模块都能在任何地方运行。

下面是我们一个带双向绑定的 组合框 示例,使用的是 Svelte

Combobox.svelte
<script>  let input = $state("");  function handleSubmit() {    alert(`已选中: ${input}`);  }</script><form onsubmit={handleSubmit}>  <label for="autocomplete" class="block text-sm/6 font-medium text-gray-900">分配给</label>  <el-autocomplete class="relative mt-2 block">    <input bind:value={input} id="autocomplete" type="text" class="block w-full rounded-md ..." />    <button type="button" class="absolute inset-y-0 right-0 flex ...">      <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="size-5 text-gray-400">        <path d="M5.22 8.22a.75.75 ..." clip-rule="evenodd" fill-rule="evenodd" />      </svg>    </button>    <el-options anchor="bottom end" popover class="max-h-60 w-(--input-width) [--anchor-gap:--spacing(1)] ...">      <el-option value="Leslie Alexander" class="block truncate aria-selected:bg-indigo-600 aria-selected:text-white ...">Leslie Alexander</el-option>    </el-options>  </el-autocomplete>  <button type="submit">提交</button></form>

或者这是一个在 Rails 中使用的自定义选择框,可以像原生表单控件一样参与表单提交:

app/controllers/orders_controller.rb
class OrdersController < ApplicationController  def new    @cars = Car.all    @selected_car = @cars.first  end  def create    car = Car.find(params[:car_id])    flash[:notice] = "选中的车辆: #{car.name}"    redirect_to root_path  endend
app/views/orders/new.html.erb
<%= form_with do |form| %>  <%= form.label :car_id, "选择车型:" %>  <el-select name="car_id" id="car_id" value="<%= @selected_car.id %>">    <button type="button" class="grid w-full cursor-default grid-cols-1 ...">      <el-selectedcontent class="col-start-1 row-start-1 truncate pr-6">        <%= @selected_car.name %>      </el-selectedcontent>      <svg viewBox="0 0 16 16" aria-hidden="true" class="col-start-1 row-start-1 size-5 ...">        <path d="M5.22 10.22a.75.75 ..." clip-rule="evenodd" fill-rule="evenodd" />      </svg>    </button>    <el-options anchor="bottom end" popover="">      <% @cars.each do |car| %>        <el-option value="<%= car.id %>">          <span class="block truncate font-normal group-aria-selected/option:font-semibold">            <%= car.name %>          </span>          <span class="flex group-not-aria-selected/option:hidden group-focus/option:text-white in-[el-selectedcontent]:hidden ...">            <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="size-5">              <path d="M16.704 4.153a.75.75 0 ..." clip-rule="evenodd" fill-rule="evenodd" />            </svg>          </span>        </el-option>      <% end %>    </el-options>  </el-select>  <%= form.submit "下单" %><% end %>

如果你愿意,也可以在 React 中使用 Elements,而不是只能用 Headless UI 或 React Aria 这种仅适用于 React 的库:

import Link from "next/link";export function Menu() {  return (    <el-dropdown className="relative inline-block text-left">      <button className="inline-flex w-full justify-center ...">        菜单        <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" className="-mr-1 size-5 text-gray-400">          <path d="M5.22 8.22a.75.75 ..." clip-rule="evenodd" fill-rule="evenodd" />        </svg>      </button>      <el-menu anchor="bottom end" popover className="transition transition-discrete [--anchor-gap:--spacing(2)] focus:outline-hidden data-closed:scale-95 ...">        <div className="py-1">          <Link href="/" className="block px-4 py-2 focus:bg-gray-100 focus:text-gray-900 focus:outline-hidden ...">首页</Link>          <Link href="/about" className="block px-4 py-2 focus:bg-gray-100 focus:text-gray-900 focus:outline-hidden ...">关于</Link>          <Link href="/faq" className="block px-4 py-2 focus:bg-gray-100 focus:text-gray-900 focus:outline-hidden ...">常见问题</Link>        </div>      </el-menu>    </el-dropdown>  );}

现在就试试吧

所有更新后的 UI 模块和新的 Elements 库现已提供给所有 Tailwind Plus 用户。

访问像 下拉菜单命令面板 这样的 UI 模块分类,亲自体验这些更新的交互式示例,探索全新的 Elements 文档,了解所有功能如何工作,以及如何为你的项目定制。

我们迫不及待想看到你用这些东西创造出什么!

Get all of our updates directly to your inbox.
Sign up for our newsletter.