daisyui 2.1.1 → 2.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 75952f05719457c52d7085643cfeebc3b0dd2f72e4ddfc768d1ba7468431c161
4
- data.tar.gz: 9029feca27ce0d586ad608c23170afcb7bafc1f17998af22793a2b39ce8c1ce3
3
+ metadata.gz: b1bf5647c845ed4fbdf7d538e4934c7171b23039cbc9029d8c68330b82f0acd2
4
+ data.tar.gz: 9282c494a1db396b4153fda3c9696040e8c95ac7d495d0bbcf9a0be983a49fb1
5
5
  SHA512:
6
- metadata.gz: 206f1d5f22c5ac7d41df14f1984fedfa70056b5128aa97ff3affb1bb5607cb6cfe1e7c92899c469b70e763976f5fa1c300fa9dcd18f3c10fd9dd10e41734af98
7
- data.tar.gz: b65a346205a5fe198a858b3de5e191f20ee909fbe86c565e3d8aee87680ac9a1c2bf5ee681d6fdf698700063e271534ad766ac1afecfdf63ceb43557d03922b5
6
+ metadata.gz: f2fa7469a01b18c4bb28110ab4da1238ce8c846fef910e3edc488d283ed641e8cee5fb3b2fb69ca528f643283a1339114db14354a0f3d0d306b67f996923e4d8
7
+ data.tar.gz: 282f9add88cc7e82eabd013b81bb4e1470c8809f6687ada460470ef97a472d228182154f644c56a7df000f4714dd8d51eca4bbc5692f5ddbbdba7607e84617f4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,68 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ### Documentation for the variants API
4
+
5
+ - Docs site: "Variants, states and slots" (when to use `variant`, `flag`,
6
+ `compound`, `state`/`on` and `slot`, the match values, render order and its
7
+ three pitfalls, introspection) and "Tailwind sources for your components"
8
+ (`--app`, the rake task, committing the file, the `stale?` spec, stylelint).
9
+ - MCP server: `get_component` includes the component's `variants.schema`, and a
10
+ new `variants_guide` tool returns a short guide to the DSL with the pitfalls.
11
+ - README: the variants section links the guide and lists the pitfalls.
12
+
13
+ ### Variants, states and slots
14
+
15
+ A `variants` block declares what a component that owns its design needs
16
+ beyond `register_modifiers`, as data the gem owns (#67). See the README's
17
+ "Variants, states and slots".
18
+
19
+ ```ruby
20
+ class Widget < DaisyUI::Button
21
+ variants do
22
+ base "font-medium"
23
+ default :size, :md
24
+ compound style: :outline, color: nil, class: "[--btn-bg:var(--color-base-100)]"
25
+ state :hover, "hover"
26
+ state :open, "[.dropdown:is(:focus-within,[open],:has(:popover-open))>&]"
27
+ on %i[hover open], style: %i[ghost link], class: "[--btn-bg:transparent]"
28
+ end
29
+ end
30
+ ```
31
+
32
+ - `variant` and `flag` are `register_modifiers` (groups, ClassMerge,
33
+ inheritance unchanged); `base` appends to the inherited base; `default`
34
+ applies when the caller names no member of a group.
35
+ - `compound(**match, class:, set:)` adds classes when modifiers hold together
36
+ or one holds and another is absent (`color: nil`, `shape: [nil, :row]`,
37
+ `color: true`, flags `true`/`false`); `set:` selects members for the
38
+ caller's empty groups, in declaration order.
39
+ - `state :name, "variant"` names a Tailwind variant once; `on(states, …)`
40
+ writes a declaration once and renders it under each state.
41
+ - `slot :name, "classes"`, per-slot Hash values, and `slot_classes` /
42
+ `slot_options` for components with parts.
43
+ - Defaults and `set:` members are resolved, not left to ClassMerge: a default
44
+ never renders beside the member the caller gave, with class merging off too.
45
+ - A typo (a group, member, flag, state or slot the component does not have)
46
+ raises `ArgumentError` where it is declared.
47
+ - `Component.variants.schema` returns plain, frozen data: variants, flags,
48
+ defaults, compounds, `on` rules, states and slots.
49
+ - An unknown modifier's error now lists the component's groups:
50
+ `… has no modifier :huge. Variants: size: xs, sm, …`.
51
+ - Registries are frozen by `DaisyUI.finalize!` like the others.
52
+
53
+ ### Tailwind sources for an app's own components
54
+
55
+ State-prefixed classes appear nowhere literally in an app's source, so
56
+ `bin/rails "daisyui:tailwind[app/assets/stylesheets/generated/daisyui_app.css]"`
57
+ (or `daisyui-tailwind --app --require FILE`) writes `@source inline(…)` for
58
+ every `DaisyUI::Base` subclass the app defines: its classes, compounds, slots
59
+ and each `on` class per state. Sorted, versionless output meant to be
60
+ committed; `DaisyUI::Tailwind.stale?(path)` fails a spec when a declaration
61
+ changed and the file was not regenerated. From Ruby:
62
+ `DaisyUI::Tailwind.sources(app: true)`.
63
+
64
+ ## [2.1.1] - 2026-10-17
65
+
3
66
  ### Tailwind sources for only the components an app uses
4
67
 
5
68
  `bundle exec daisyui-tailwind app -o app/assets/stylesheets/generated/daisyui.css`
data/README.md CHANGED
@@ -216,6 +216,42 @@ language can read it directly from `$(bundle info daisyui --path)`:
216
216
  - `classes`: everything it can render: `name`, the modifiers and the class words in its Ruby. Words that are not classes cost nothing.
217
217
  - `shared`: base.rb's classes, which every component can render (`:skeleton`, the colour modifiers). Include them with any component.
218
218
 
219
+ ### Your own components (`--app`)
220
+
221
+ A component you declare with [`variants`](#variants-states-and-slots) renders
222
+ classes that appear nowhere literally in your source: an `on %i[hover open]`
223
+ rule writes `hover:…` and `[.dropdown…>&]:…` at render time. Tailwind only
224
+ compiles what it finds written out, so hand it your components' classes too:
225
+
226
+ ```sh
227
+ bin/rails "daisyui:tailwind[app/assets/stylesheets/generated/daisyui_app.css]"
228
+ # without Rails: load your components, then walk them
229
+ bundle exec daisyui-tailwind --app --require ./config/components.rb -o app/assets/stylesheets/generated/daisyui_app.css
230
+ ```
231
+
232
+ ```css
233
+ @import "./generated/daisyui.css"; /* the gem's components you use */
234
+ @import "./generated/daisyui_app.css"; /* your own: variants, compounds, states, slots */
235
+ ```
236
+
237
+ With the app loaded (the rake task eager-loads Rails), it walks every
238
+ `DaisyUI::Base` subclass defined outside the gem and writes one
239
+ `@source inline(…)` per component, sorted by name: its component class,
240
+ modifiers, `base`, compound and slot classes, and each `on` class once per
241
+ state. `--responsive` and `--prefix` work as above; `--format json` too. From
242
+ Ruby: `DaisyUI::Tailwind.sources(app: true)`, or `app: [Widget, Picker]` for
243
+ some. The header carries no version and nothing depends on the machine, so the
244
+ file only changes when a declaration does: **commit it**, and let a spec fail
245
+ when it is stale, the way this gem checks its own manifest:
246
+
247
+ ```ruby
248
+ # spec/daisyui_tailwind_spec.rb
249
+ it("has fresh Tailwind sources") { expect(DaisyUI::Tailwind.stale?("app/assets/stylesheets/generated/daisyui_app.css")).to be(false) }
250
+ ```
251
+
252
+ `stale?` eager-loads a Rails app before it compares. The CSS build then never
253
+ boots the app; it only imports the file.
254
+
219
255
  ## Class merging
220
256
 
221
257
  Components merge their classes so the last conflicting class wins, for Tailwind
@@ -306,6 +342,116 @@ end
306
342
  Keyword arguments are not validated: `Toggle(primray: true)` renders a
307
343
  `primray` attribute, because unknown keywords are HTML attributes.
308
344
 
345
+ ## Variants, states and slots
346
+
347
+ The full guide, with when to use which declaration, is on the docs site:
348
+ [Variants, states and slots](https://daisyui.phlex.fun/docs/variants). Four things
349
+ to know before you write one:
350
+
351
+ - A `default` renders after the caller's modifiers, so it beats one that sets the same property. Put such a value in `base`.
352
+ - A `compound` or `on` rule also renders after the caller's modifiers. Leave the member it would override out of the match (`layout: [nil, :row]`).
353
+ - `on` writes `"<state>:<class>"`, the state outermost. A rule that needs a child selector first (`[&_li]:hover:...`) stays literal in `base`.
354
+ - `on` classes appear nowhere literally: generate [Tailwind sources](#your-own-components---app) for your components or Tailwind never compiles them.
355
+
356
+ `register_modifiers` maps a symbol to classes. A component that owns its design
357
+ also needs classes for two modifiers together, the same declarations under a
358
+ browser state, and classes for its parts. Declare them in a `variants` block:
359
+
360
+ ```ruby
361
+ class Widget < DaisyUI::Button
362
+ OPEN = "[.dropdown:is(:focus-within,[open],:has(:popover-open))>&]"
363
+
364
+ variants do
365
+ base "font-medium" # after the inherited base (btn)
366
+ variant :size, sm: "btn-sm [--fontsize:var(--text-sm)]" # overrides one inherited member
367
+ default :size, :md # when the caller names no size
368
+
369
+ compound style: :outline, color: nil, class: "[--btn-bg:var(--color-base-100)]"
370
+ compound style: %i[ghost link], class: "[--btn-border:transparent]"
371
+
372
+ state :hover, "hover"
373
+ state :open, OPEN
374
+ on %i[hover open], style: %i[ghost link], class: "[--btn-bg:color-mix(in_oklab,var(--color-base-content)_8%,transparent)]"
375
+ end
376
+ end
377
+
378
+ Widget() # btn font-medium btn-md
379
+ Widget(:ghost, :sm) # btn font-medium btn-ghost btn-sm [--fontsize:…] [--btn-border:transparent]
380
+ # hover:[--btn-bg:…] [.dropdown…>&]:[--btn-bg:…]
381
+ ```
382
+
383
+ | Declaration | |
384
+ |---|---|
385
+ | `variant :group, member: "classes", …` | Alternatives: the grouped `register_modifiers(group: { … })`. Members merge as one ClassMerge group. |
386
+ | `flag :name, "classes"` | An on/off modifier: an ungrouped `register_modifiers` entry. `flag :name` alone is a `register_flags` flag, no class, for compounds to match. |
387
+ | `base "classes"` | Classes every instance gets, appended to the inherited base. |
388
+ | `default :group, :member` | The member used when the caller names none of the group. Never rendered beside a member the caller gives. |
389
+ | `compound(**match, class:, set: {})` | `class:` when every key of `match` holds. `set: { group: :member }` selects members for the caller's empty groups; later compounds see them. Applied in order, after defaults. |
390
+ | `state :name, "variant"` | Names a Tailwind variant once: `"hover"`, `"focus-visible"`, `"[.dropdown[open]>&]"`. |
391
+ | `on(states, **match, class:)` | Like `compound`, with each class written once per state: `on %i[hover open], class: "a"` renders `hover:a [.dropdown…>&]:a`. |
392
+ | `slot :name, "classes"` | A part of the component (see below). |
393
+
394
+ A `match` value says when a group or flag holds:
395
+
396
+ | Value | Holds when |
397
+ |---|---|
398
+ | `:member` | that member is selected: given, defaulted or set by an earlier compound |
399
+ | `[:a, :b]` | any of them is; a `nil` in the list means "or none": `shape: [nil, :row]` |
400
+ | `nil` / `false` | no member of the group is |
401
+ | `true` | any member of the group is: `color: true` |
402
+ | `true` / `false` | for a flag: it is on / off |
403
+
404
+ Classes render in this order, merged by ClassMerge: the component class, `base`,
405
+ the given modifiers, the defaulted and set members, compounds, `on` rules,
406
+ `responsive:`, then `class:`. Every declared class goes through
407
+ `DaisyUI.configuration.prefix` like a modifier (`hover:d-…` with `prefix = "d-"`).
408
+ A typo fails where it is declared, at boot: a `compound`, `on`, `default` or
409
+ `set:` naming a group, member, flag, state or slot the component does not have
410
+ raises `ArgumentError`. `class` and `set` are reserved keys: a group called that could never be matched, and nothing stops you naming one.
411
+
412
+ **Slots.** A component with parts declares each one; a variant, compound or `on`
413
+ value can be a Hash keyed by slot (`root` is the component itself):
414
+
415
+ ```ruby
416
+ class Picker < DaisyUI::Dropdown
417
+ variants do
418
+ slot :trigger
419
+ slot :menu, "mt-2"
420
+ variant :reach, wide: { root: "picker-wide", menu: "w-96" }
421
+ state :open, "[.dropdown:is(:focus-within,[open],:has(:popover-open))>&]"
422
+ on :open, class: { menu: "shadow-lg" }
423
+ end
424
+
425
+ def trigger(*mods, **options, &) = render DaisyUI::Button.new(*mods, **slot_options(:trigger, options), &)
426
+ def list(*mods, **options, &) = render DaisyUI::Menu.new(*mods, class: slot_classes(:menu, options), **options, &)
427
+ end
428
+ ```
429
+
430
+ `slot_classes(:menu, options)` returns the slot's classes, those of every
431
+ selected member, compound and `on` rule for it, then `options.delete(:class)`,
432
+ merged. `slot_options(:trigger, options)` returns `options` with that `class:`.
433
+
434
+ **Introspection.** `Widget.variants.schema` is plain, frozen data for a docs
435
+ page, a component gallery or a design-tool parity check:
436
+
437
+ ```ruby
438
+ {
439
+ base: "btn font-medium",
440
+ variants: { style: %i[ghost link outline dash soft], size: %i[lg md xs xl sm], … }, # a re-declared member moves last
441
+ flags: %i[active disabled wide block],
442
+ defaults: { size: :md },
443
+ compounds: [{ match: { style: :outline, color: nil }, class: "[--btn-bg:…]", set: {} }, …],
444
+ on: [{ states: %i[hover open], match: { style: %i[ghost link] }, class: "[--btn-bg:…]" }],
445
+ states: { hover: "hover", open: "[.dropdown…>&]" },
446
+ slots: { root: "btn font-medium" }
447
+ }
448
+ ```
449
+
450
+ A component that only calls `register_modifiers` has a schema too (variants and
451
+ flags). An unknown modifier's error names the groups:
452
+ `Widget has no modifier :huge. Variants: style: ghost, link, outline, dash, soft; size: lg, md, xs, xl, sm; …`.
453
+ The state-prefixed classes need [Tailwind sources of their own](#your-own-components---app).
454
+
309
455
  ## Threads, fibers and Ractors
310
456
 
311
457
  Components and `DaisyUI::ClassMerge` are safe to share between threads (Puma)
@@ -554,6 +700,9 @@ Add to your Claude Code MCP settings (`~/.claude.json` or project `.claude.json`
554
700
  - **list_components** - List all available DaisyUI components
555
701
  - **get_component** - Get detailed info about a specific component (modifiers, usage examples)
556
702
  - **search_components** - Search components by name or modifier
703
+ - **variants_guide** - How to declare a component with `variants`: which declaration to use, the match values, render order and the pitfalls
704
+
705
+ `get_component` also returns the component's variants schema (groups, flags, defaults, compounds, `on` rules, states and slots).
557
706
 
558
707
  # Usage
559
708
 
data/lib/daisy_ui/base.rb CHANGED
@@ -35,6 +35,8 @@ module DaisyUI
35
35
  # This renders: <div class="[generated classes]" id="..." data-...="..." ...>
36
36
  #
37
37
  class Base < Phlex::HTML
38
+ include Variants::Rendering
39
+
38
40
  BOOLS = [true, false].freeze
39
41
  # Shared color modifiers used across multiple components
40
42
  # Maps color names to their DaisyUI background/text class combinations
@@ -145,6 +147,16 @@ module DaisyUI
145
147
  @component_class = Shareable.share(component_class)
146
148
  end
147
149
 
150
+ # What a `variants` block declared beyond modifiers: base, defaults,
151
+ # compounds, states, `on` rules and slots (see DaisyUI::Variants).
152
+ def variant_definitions
153
+ @variant_definitions || Variants::EMPTY
154
+ end
155
+
156
+ def variant_definitions=(definitions)
157
+ @variant_definitions = Shareable.share(definitions)
158
+ end
159
+
148
160
  # Positional symbols a component handles itself (e.g. Dropdown's
149
161
  # :popover): accepted as modifiers, but they emit no class.
150
162
  def flags
@@ -183,6 +195,7 @@ module DaisyUI
183
195
  subclass.modifiers = (modifiers || {}).dup
184
196
  subclass.modifier_groups = modifier_groups.transform_values(&:dup)
185
197
  subclass.flags = flags.dup
198
+ subclass.variant_definitions = variant_definitions
186
199
  # Inherit component_class if it was explicitly set
187
200
  return unless instance_variable_defined?(:@component_class)
188
201
 
@@ -216,11 +229,15 @@ module DaisyUI
216
229
  ClassMerge.reset!
217
230
  end
218
231
 
232
+ # Declares variants, compounds, defaults, states and slots; returns the
233
+ # declarations, so `Widget.variants.schema` describes them.
234
+ def variants(&) = Variants.build(self, &)
235
+
219
236
  # @api private Called by DaisyUI.finalize! for every component: derives
220
237
  # component_class on the main Ractor and makes sure every registry is
221
238
  # frozen for non-main Ractors.
222
239
  def finalize!
223
- [component_class, modifiers, modifier_groups, flags].each { |registry| Shareable.share(registry) }
240
+ [component_class, modifiers, modifier_groups, flags, variant_definitions].each { |registry| Shareable.share(registry) }
224
241
  self
225
242
  end
226
243
  end
@@ -246,17 +263,20 @@ module DaisyUI
246
263
  # Main extension points - override these in your project!
247
264
 
248
265
  # Builds the final CSS class string from:
249
- # 1. Component base class (e.g., "dropdown")
266
+ # 1. Component base class (e.g., "dropdown"), then a `variants` base
250
267
  # 2. Modifier classes (e.g., "dropdown-top")
251
- # 3. Responsive classes (e.g., "sm:dropdown-hover")
252
- # 4. User-provided classes via `class:` option
268
+ # 3. What a `variants` block derives: defaults, compounds, `on` states
269
+ # 4. Responsive classes (e.g., "sm:dropdown-hover")
270
+ # 5. User-provided classes via `class:` option
253
271
  #
254
272
  # Note: This method DESTRUCTIVELY removes :class from options
255
273
  # so it won't appear again in attributes
256
274
  def classes
257
275
  merge_classes(
258
276
  base_class,
277
+ variant_base_classes,
259
278
  *modifier_classes,
279
+ *variant_classes,
260
280
  *responsive_classes,
261
281
  options.delete(:class)
262
282
  )
@@ -11,6 +11,10 @@ module DaisyUI
11
11
  # * appends the JS dir to the asset load path so Propshaft/Sprockets serve
12
12
  # the .min.js and its sourcemap (Sprockets also needs them precompiled).
13
13
  #
14
+ # Rails also loads lib/tasks/daisyui.rake from here: `bin/rails
15
+ # daisyui:tailwind[OUTPUT]` writes the Tailwind sources of the app's own
16
+ # components (DaisyUI::Tailwind::AppSources).
17
+ #
14
18
  # The controllers are opt-in at the markup level (`Dropdown(:popover, stimulus:
15
19
  # true)`, `Tooltip(:popover)`) and not preloaded, so a page that never uses
16
20
  # one never fetches it.
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DaisyUI
4
+ class McpServer
5
+ # The text of the `variants_guide` tool: the `variants` DSL on one page, with
6
+ # the three ways it surprises. Keep it in step with the README's "Variants,
7
+ # states and slots" and the docs site page.
8
+ module VariantsGuide
9
+ TEXT = <<~MARKDOWN
10
+ # Variants, states and slots
11
+
12
+ A component that owns its design declares it in a `variants` block on a
13
+ `DaisyUI::Base` subclass. `get_component` shows the result as a schema.
14
+
15
+ ```ruby
16
+ class Widget < DaisyUI::Button
17
+ variants do
18
+ base "font-medium"
19
+ variant :size, sm: "btn-sm", md: "btn-md"
20
+ default :size, :md
21
+ compound style: :outline, color: nil, class: "[--btn-bg:var(--color-base-100)]"
22
+ state :hover, "hover"
23
+ on :hover, style: %i[ghost link], class: "[--btn-bg:transparent]"
24
+ end
25
+ end
26
+ ```
27
+
28
+ ## Which declaration
29
+
30
+ | Need | Use |
31
+ |---|---|
32
+ | Alternatives, only one applies (sizes, colors) | `variant :group, member: "classes"` |
33
+ | An on/off modifier | `flag :name, "classes"`; `flag :name` alone has no class and exists to be matched |
34
+ | Classes every instance gets | `base "classes"` (appended to the inherited base) |
35
+ | A member when the caller names none of the group | `default :group, :member` |
36
+ | Classes when modifiers hold together or one is absent | `compound(**match, class:, set: {})` |
37
+ | The same classes under a browser state | `state :name, "<tailwind variant>"` once, then `on(states, **match, class:)` |
38
+ | A part of the component (trigger, menu) | `slot :name, "classes"`, then `slot_classes(name, options)` / `slot_options(name, options)` in the part's method |
39
+
40
+ ## Match values (`compound`, `on`)
41
+
42
+ | Value | Holds when |
43
+ |---|---|
44
+ | `:member` | that member is selected: given, defaulted or set by an earlier compound |
45
+ | `[:a, :b]` | any of them is; a `nil` in the list means "or none": `shape: [nil, :row]` |
46
+ | `nil` / `false` | no member of the group is |
47
+ | `true` | any member of the group is: `color: true` |
48
+ | `true` / `false` | for a flag: on / off |
49
+
50
+ `set: { group: :member }` on a compound selects a member for the caller's empty
51
+ groups; later compounds see it. `class` and `set` are reserved keys: a group called that could never be matched.
52
+ A `class:` or a modifier value can be a Hash keyed by slot (`root` is the component).
53
+
54
+ ## Render order
55
+
56
+ component class, `base`, the caller's modifiers, defaulted and set members,
57
+ compounds, `on` rules, `responsive:`, then the caller's `class:`. ClassMerge
58
+ resolves conflicts, the last one wins.
59
+
60
+ ## Pitfalls
61
+
62
+ 1. A `default` renders after the caller's modifiers. A default member's class
63
+ beats a caller modifier that sets the same property (a default size's
64
+ `--fontsize` beats an `:inline` modifier's). Put a value both can set in
65
+ `base`, not in a default.
66
+ 2. A compound or `on` rule also renders after the caller's modifiers, so it
67
+ can override a modifier's arbitrary property. Leave the conflicting member
68
+ out of the match (`layout: [nil, :inline, :row]`).
69
+ 3. `on` writes `"<state>:<class>"`: the state prefix is outermost. A rule that
70
+ needs a child selector first (`[&_li]:hover:bg-...`) cannot be an `on :hover`
71
+ rule; keep it literal in `base`.
72
+
73
+ Also: do not name a group after an inherited one with a different meaning
74
+ (`Button` already has `shape`); a state selector with a child combinator puts `>`
75
+ in the class attribute, so parse rendered HTML with Nokogiri, not a regexp.
76
+
77
+ ## Tailwind sources
78
+
79
+ Tailwind v4 compiles only classes written out literally, and an `on` class is
80
+ written nowhere. Generate sources for your own components, commit the file,
81
+ import it from the Tailwind entry and fail a spec when it is stale:
82
+
83
+ ```sh
84
+ bin/rails "daisyui:tailwind[app/assets/stylesheets/generated/daisyui_app.css]"
85
+ bundle exec daisyui-tailwind --app --require ./config/components.rb -o app/assets/stylesheets/generated/daisyui_app.css
86
+ ```
87
+
88
+ ```ruby
89
+ expect(DaisyUI::Tailwind.stale?("app/assets/stylesheets/generated/daisyui_app.css")).to be(false)
90
+ ```
91
+
92
+ ## Introspection
93
+
94
+ `Widget.variants.schema` returns `base`, `variants`, `flags`, `defaults`,
95
+ `compounds`, `on`, `states` and `slots` as frozen data.
96
+ MARKDOWN
97
+ end
98
+ end
99
+ end
@@ -18,7 +18,7 @@ module DaisyUI
18
18
  },
19
19
  {
20
20
  name: "get_component",
21
- description: "Get detailed information about a specific DaisyUI component including all modifiers and usage examples",
21
+ description: "Get detailed information about a specific DaisyUI component including all modifiers, its variants schema (groups, flags, defaults, compounds, on rules, states, slots) and usage examples",
22
22
  inputSchema: {
23
23
  type: "object",
24
24
  properties: {
@@ -37,6 +37,11 @@ module DaisyUI
37
37
  },
38
38
  required: ["query"]
39
39
  }
40
+ },
41
+ {
42
+ name: "variants_guide",
43
+ description: "How to declare a component's design with the variants DSL (variant, flag, default, compound, state/on, slot): when to use which, the match values, render order and the pitfalls",
44
+ inputSchema: { type: "object", properties: {}, required: [] }
40
45
  }
41
46
  ].freeze
42
47
  Shareable.make(TOOLS) # the Hashes inside too
@@ -113,6 +118,8 @@ module DaisyUI
113
118
  get_component(arguments["component"])
114
119
  when "search_components"
115
120
  search_components(arguments["query"])
121
+ when "variants_guide"
122
+ VariantsGuide::TEXT
116
123
  else
117
124
  return { content: [{ type: "text", text: "Unknown tool: #{tool_name}" }], isError: true }
118
125
  end
@@ -160,6 +167,14 @@ module DaisyUI
160
167
  ## Available Modifiers
161
168
  #{modifier_list.empty? ? 'No modifiers' : modifier_list}
162
169
 
170
+ ## Variants schema
171
+
172
+ Groups, flags, defaults, compounds, `on` rules, states and slots. `variants_guide` explains them.
173
+
174
+ ```json
175
+ #{JSON.pretty_generate(klass.variants.schema)}
176
+ ```
177
+
163
178
  ## Usage Examples
164
179
 
165
180
  ```ruby
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DaisyUI
4
+ module Tailwind
5
+ # The Tailwind sources for an app's own components (DaisyUI::Base
6
+ # subclasses outside the gem), read from their declarations rather than
7
+ # the gem's manifest: the component class, every modifier, and what a
8
+ # `variants` block declares, `on` rules written out once per state. Those
9
+ # state-prefixed classes appear nowhere literally in the app's source, so
10
+ # Tailwind only compiles them from here.
11
+ #
12
+ # One `@source inline(…)` per component, sorted by name, under a header
13
+ # with no version, so the file changes only when a declaration does: it
14
+ # is meant to be committed, and DaisyUI::Tailwind.stale? checks it.
15
+ class AppSources
16
+ HEADER = "/* Generated by daisyui-tailwind --app from the app's DaisyUI components: do not edit. */\n"
17
+
18
+ def initialize(components, responsive: [], prefix: nil)
19
+ anonymous = components.reject(&:name)
20
+ raise ArgumentError, "an anonymous class has no name to label its block: #{anonymous.inspect}" unless anonymous.empty?
21
+
22
+ @components = components.sort_by(&:name)
23
+ @breakpoints = responsive.map(&:to_s)
24
+ @prefix = prefix.to_s
25
+ Sources.validate_options!(@breakpoints, @prefix)
26
+ end
27
+
28
+ def render(format)
29
+ case format.to_s
30
+ when "css" then to_css
31
+ when "json" then to_json
32
+ else raise ArgumentError, "unknown format #{format.inspect}: css or json"
33
+ end
34
+ end
35
+
36
+ def to_css
37
+ return "#{HEADER}/* No app components. */\n" if @components.empty?
38
+
39
+ lines = @components.flat_map { |component| ["/* #{component.name} */", %(@source inline("#{classes(component).join(' ')}");)] }
40
+ "#{HEADER}#{lines.join("\n")}\n"
41
+ end
42
+
43
+ def to_json(*)
44
+ require "json"
45
+
46
+ components = @components.to_h do |component|
47
+ [component.name, { "name" => component.component_class&.to_s, "classes" => classes(component) }]
48
+ end
49
+ json = { "components" => components, "classes" => components.values.flat_map { |entry| entry["classes"] }.uniq.sort }
50
+ "#{JSON.pretty_generate(json)}\n"
51
+ end
52
+
53
+ private
54
+
55
+ def classes(component)
56
+ classes = component.variants.literal_classes(prefix: @prefix) + responsive_classes(component)
57
+ invalid = classes.reject { |token| Candidate.valid?(token) }
58
+ return classes.uniq.sort if invalid.empty?
59
+
60
+ raise ArgumentError, "#{component.name} declares #{invalid.map(&:inspect).join(', ')}, " \
61
+ "which Tailwind cannot read from @source inline(…)"
62
+ end
63
+
64
+ # What `responsive:` renders: the component class and the modifiers.
65
+ def responsive_classes(component)
66
+ return [] if @breakpoints.empty?
67
+
68
+ tokens = [component.component_class&.to_s, *(component.modifiers || {}).values].compact.flat_map(&:split)
69
+ @breakpoints.flat_map { |breakpoint| tokens.map { |token| "#{breakpoint}:#{@prefix}#{token}" } }
70
+ end
71
+ end
72
+ end
73
+ end
@@ -13,6 +13,10 @@ module DaisyUI
13
13
  that the Ruby and templates under each PATH use, plus the components those
14
14
  render. @import the output from your Tailwind entry.
15
15
 
16
+ With --app, prints the sources of the app's own components (DaisyUI::Base
17
+ subclasses, after requiring each --require FILE) instead: their variants,
18
+ compounds and state-prefixed classes, for a file you commit.
19
+
16
20
  TEXT
17
21
 
18
22
  def self.run(argv, out: $stdout, err: $stderr) = new(out:, err:).run(argv)
@@ -25,16 +29,16 @@ module DaisyUI
25
29
  def run(argv)
26
30
  require "optparse"
27
31
 
28
- options = { components: [], format: "css", responsive: [] }
32
+ options = { components: [], format: "css", responsive: [], require: [] }
29
33
  parser = parser(options)
30
34
  paths = parser.parse(argv)
31
35
  return help(parser) if options[:help]
32
- return usage(parser) if paths.empty? && options[:components].empty?
36
+ return usage(parser) if paths.empty? && options[:components].empty? && !options[:app]
33
37
 
34
- result = Tailwind.sources(scan: paths, **options.slice(:components, :format, :responsive, :prefix))
38
+ result = Tailwind.sources(scan: paths, **sources_options(options))
35
39
  options[:output] ? write(options[:output], result) : @out.write(result)
36
40
  0
37
- rescue OptionParser::ParseError, ArgumentError, SystemCallError => e
41
+ rescue OptionParser::ParseError, ArgumentError, SystemCallError, LoadError => e
38
42
  @err.puts "daisyui-tailwind: #{e.message}"
39
43
  1
40
44
  end
@@ -53,10 +57,23 @@ module DaisyUI
53
57
  options[:responsive].concat(breakpoints)
54
58
  end
55
59
  opts.on("--prefix PREFIX", "The DaisyUI.configuration.prefix your app sets") { |prefix| options[:prefix] = prefix }
60
+ opts.on("--app", "The app's own components instead of the gem's") { options[:app] = true }
61
+ opts.on("-r", "--require FILE", "With --app: load FILE first, e.g. config/environment.rb (repeatable)") do |file|
62
+ options[:require] << file
63
+ end
56
64
  opts.on("-h", "--help", "Show this help") { options[:help] = true }
57
65
  end
58
66
  end
59
67
 
68
+ # With --app: load the app's files, then freeze DaisyUI as boot does.
69
+ def sources_options(options)
70
+ if options[:app]
71
+ options[:require].each { |file| require File.expand_path(file) }
72
+ DaisyUI.finalize!
73
+ end
74
+ options.slice(:components, :format, :responsive, :prefix, :app)
75
+ end
76
+
60
77
  def help(parser)
61
78
  @out.puts parser
62
79
  0
@@ -22,6 +22,16 @@ module DaisyUI
22
22
 
23
23
  attr_reader :components
24
24
 
25
+ # @api private Shared with AppSources.
26
+ def self.validate_options!(breakpoints, prefix)
27
+ breakpoints.each do |breakpoint|
28
+ raise ArgumentError, "invalid breakpoint #{breakpoint.inspect}: give the variant name, e.g. sm or @md" unless BREAKPOINT.match?(breakpoint)
29
+ end
30
+ return if prefix.empty? || PREFIX.match?(prefix)
31
+
32
+ raise ArgumentError, "invalid prefix #{prefix.inspect}"
33
+ end
34
+
25
35
  def initialize(manifest, components, responsive: [], prefix: nil)
26
36
  @manifest = manifest
27
37
  @components = components
@@ -90,14 +100,7 @@ module DaisyUI
90
100
 
91
101
  def inline(classes) = %(@source inline("#{classes.join(' ')}");)
92
102
 
93
- def validate!
94
- @breakpoints.each do |breakpoint|
95
- raise ArgumentError, "invalid breakpoint #{breakpoint.inspect}: give the variant name, e.g. sm or @md" unless BREAKPOINT.match?(breakpoint)
96
- end
97
- return if @prefix.empty? || PREFIX.match?(@prefix)
98
-
99
- raise ArgumentError, "invalid prefix #{@prefix.inspect}"
100
- end
103
+ def validate! = self.class.validate_options!(@breakpoints, @prefix)
101
104
  end
102
105
  end
103
106
  end
@@ -37,11 +37,55 @@ module DaisyUI
37
37
  # components. `responsive:` adds breakpoint variants (`%w[sm md]`) of the
38
38
  # classes `responsive:` renders; `prefix:` defaults to the
39
39
  # DaisyUI.configuration.prefix the components render with.
40
- def sources(scan: [], components: [], format: :css, responsive: [], prefix: DaisyUI.configuration.prefix)
40
+ #
41
+ # `app: true` gives the app's own components instead (app_components),
42
+ # or `app: [Widget, …]` the ones named: see AppSources. Their file is
43
+ # separate from the gem components' and meant to be committed.
44
+ def sources(scan: [], components: [], app: false, format: :css, responsive: [], prefix: DaisyUI.configuration.prefix)
45
+ return app_sources(app, scan:, components:).new(app == true ? app_components : app, responsive:, prefix:).render(format) if app
46
+
41
47
  manifest = self.manifest
42
48
  used = used_components(scan:, components:, manifest:)
43
49
  Sources.new(manifest, used, responsive:, prefix:).render(format)
44
50
  end
51
+
52
+ # The app's DaisyUI::Base subclasses, sorted by name: every named one
53
+ # whose constant still names it (a class a code reload replaced is
54
+ # skipped), defined outside the gem's lib/ or a gem component the app
55
+ # reopened to declare variants (the gem's own never do). A Rails app is
56
+ # eager-loaded first.
57
+ def app_components
58
+ ::Rails.application.eager_load! if defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
59
+ ClassMerge::DaisyGroups.components.select { |component| current?(component) && app_owned?(component) }.sort_by(&:name)
60
+ end
61
+
62
+ # Whether the committed app sources at `path` differ from what the
63
+ # declarations give now (or the file is missing): a spec can fail on it,
64
+ # the way the gem's own manifest spec does.
65
+ def stale?(path, app: true, **)
66
+ !File.file?(path) || File.read(path) != sources(app:, **)
67
+ end
68
+
69
+ private
70
+
71
+ def app_sources(app, scan:, components:)
72
+ return AppSources if scan.empty? && components.empty?
73
+
74
+ raise ArgumentError, "app: gives the app's own components, a file of its own; " \
75
+ "scan: and components: give the gem's (#{app.inspect} with #{(scan + components).inspect})"
76
+ end
77
+
78
+ def app_owned?(component)
79
+ return true unless Object.const_source_location(component.name)&.first.to_s.start_with?(File.join(ROOT, "lib", ""))
80
+
81
+ !component.variant_definitions.equal?(Variants::EMPTY)
82
+ end
83
+
84
+ def current?(component)
85
+ Object.const_get(component.name).equal?(component)
86
+ rescue NameError
87
+ false
88
+ end
45
89
  end
46
90
  end
47
91
  end
@@ -23,9 +23,16 @@ module DaisyUI
23
23
  name = "#{name}##{part}" if part
24
24
  message = "#{name} has no modifier #{modifier.inspect}"
25
25
  suggestions = DidYouMean::SpellChecker.new(dictionary: known.map(&:to_s)).correct(modifier.to_s)
26
- return message if suggestions.empty?
26
+ message = "#{message}. Did you mean? #{suggestions.map { |s| ":#{s}" }.join(', ')}" unless suggestions.empty?
27
+ groups.empty? ? message : "#{message}. Variants: #{groups}"
28
+ end
29
+
30
+ # The component's groups and their members, "size: sm, lg; color: …";
31
+ # a sub-component part has modifiers of its own, so none for it.
32
+ def groups
33
+ return "" if part || !component.respond_to?(:modifier_groups)
27
34
 
28
- "#{message}. Did you mean? #{suggestions.map { |s| ":#{s}" }.join(', ')}"
35
+ component.modifier_groups.map { |group, members| "#{group}: #{members.join(', ')}" }.join("; ")
29
36
  end
30
37
  end
31
38
  end
@@ -3,5 +3,5 @@
3
3
  module DaisyUI
4
4
  # This timestamp is automatically updated when releasing a new version
5
5
  # Format: YYYY-MM-DD HH:MM:SS UTC
6
- UPDATED_AT = "2026-10-17 07:27:19 UTC"
6
+ UPDATED_AT = "2026-10-26 18:45:50 UTC"
7
7
  end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DaisyUI
4
+ class Variants
5
+ # What a `compound` or `on` match means, and the checks that make a typo
6
+ # fail where it is declared (at boot) rather than never match:
7
+ #
8
+ # | value | matches when |
9
+ # |------------------|-----------------------------------------------------|
10
+ # | `:member` | that member is selected (given, defaulted or set) |
11
+ # | `[:a, :b]` | any of them is; a `nil` in the list means "or none" |
12
+ # | `nil` / `false` | no member of that group is |
13
+ # | `true` | any member of that group is |
14
+ # | `true` / `false` | a flag is on / off |
15
+ #
16
+ # @api private
17
+ class Match
18
+ def initialize(klass)
19
+ @klass = klass
20
+ end
21
+
22
+ # `selection` is { group => member or nil }, `on` the flags given.
23
+ def self.holds?(match, selection, on)
24
+ match.all? do |key, value|
25
+ if selection.key?(key)
26
+ member = selection[key]
27
+ case value
28
+ when true then !member.nil?
29
+ when false, nil then member.nil?
30
+ when Array then value.include?(member)
31
+ else member == value
32
+ end
33
+ else
34
+ on.include?(key) == value
35
+ end
36
+ end
37
+ end
38
+
39
+ def match!(match)
40
+ match.each do |key, value|
41
+ if groups.key?(key)
42
+ value_members(value).each { |member| member!(key, member) }
43
+ elsif flags.include?(key)
44
+ raise ArgumentError, "#{owner}: flag #{key.inspect} matches true or false, got #{value.inspect}" unless BOOLS.include?(value)
45
+ else
46
+ raise ArgumentError, "#{owner} has no variant or flag #{key.inspect}"
47
+ end
48
+ end
49
+ end
50
+
51
+ def member!(group, member)
52
+ raise ArgumentError, "#{owner} has no variant #{group.inspect}" unless groups.key?(group)
53
+ return if groups[group].include?(member)
54
+
55
+ raise ArgumentError, "#{owner}: #{group.inspect} has no member #{member.inspect}; #{group}: #{groups[group].join(', ')}"
56
+ end
57
+
58
+ def state!(name)
59
+ return if @klass.variant_definitions[:states].key?(name)
60
+
61
+ raise ArgumentError, "#{owner} has no state #{name.inspect}: declare it with `state #{name.inspect}, \"<variant>\"` first"
62
+ end
63
+
64
+ def slot!(name)
65
+ return if name == :root || @klass.variant_definitions[:slots].key?(name)
66
+
67
+ raise ArgumentError, "#{owner} has no slot #{name.inspect}: declare it with `slot #{name.inspect}` first"
68
+ end
69
+
70
+ private
71
+
72
+ BOOLS = [true, false].freeze
73
+ private_constant :BOOLS
74
+
75
+ def value_members(value)
76
+ return [] if value.nil? || value == true || value == false
77
+
78
+ Array(value).compact
79
+ end
80
+
81
+ def groups = @klass.modifier_groups
82
+
83
+ # Ungrouped modifiers and registered flags.
84
+ def flags = Schema.flags(@klass)
85
+
86
+ def owner = @klass.name || @klass.inspect
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DaisyUI
4
+ class Variants
5
+ # The instance side of the Variants API, included in DaisyUI::Base. A
6
+ # component without declarations (every gem component) skips it all.
7
+ module Rendering
8
+ private
9
+
10
+ # The `base` classes a `variants` block declared.
11
+ def variant_base_classes
12
+ apply_prefix(self.class.variant_definitions[:base])
13
+ end
14
+
15
+ # For `slot` (:root is the component itself): the classes of the
16
+ # members a default or compound selected (the root's given members are
17
+ # modifier_classes already), of every compound that holds, and of every
18
+ # `on` rule that holds, prefixed with its states.
19
+ def variant_classes(slot = :root)
20
+ definitions = self.class.variant_definitions
21
+ return [] if definitions.equal?(EMPTY)
22
+
23
+ resolution = variant_resolution
24
+ [
25
+ *variant_member_classes(resolution, slot, definitions),
26
+ *resolution.compounds.map { |compound| apply_prefix(compound[:class][slot]) },
27
+ *resolution.rules.flat_map { |rule| state_classes(rule, slot, definitions[:states]) }
28
+ ].compact
29
+ end
30
+
31
+ # Classes for one of the component's parts: the slot's own, those its
32
+ # variants, compounds and `on` rules give it, then `options[:class]`
33
+ # (removed from `options`, as component_classes does).
34
+ def slot_classes(name, options = self.options)
35
+ slots = self.class.variant_definitions[:slots]
36
+ raise ArgumentError, "#{self.class.name || self.class.inspect} has no slot #{name.inspect}" unless slots.key?(name)
37
+
38
+ merge_classes(apply_prefix(slots[name]), *variant_classes(name), options.delete(:class))
39
+ end
40
+
41
+ # `options` with the slot's classes, to splat into the part.
42
+ def slot_options(name, options = self.options)
43
+ classes = slot_classes(name, options)
44
+ classes ? options.merge(class: classes) : options
45
+ end
46
+
47
+ def variant_resolution
48
+ @variant_resolution ||= Resolution.new(self.class, modifiers)
49
+ end
50
+
51
+ def variant_member_classes(resolution, slot, definitions)
52
+ if slot == :root
53
+ resolution.implied_members.map { |member| apply_prefix(modifier_map[member]) }
54
+ else
55
+ resolution.active.map { |member| apply_prefix(definitions[:slot_classes].dig(member, slot)) }
56
+ end
57
+ end
58
+
59
+ def state_classes(rule, slot, states)
60
+ return [] unless (classes = rule[:class][slot])
61
+
62
+ rule[:states].flat_map do |state|
63
+ classes.split.map { |token| "#{states.fetch(state)}:#{apply_prefix(token)}" }
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DaisyUI
4
+ class Variants
5
+ # Which members, compounds and `on` rules hold for one instance, worked
6
+ # out explicitly rather than left to ClassMerge, so a default or a set
7
+ # member never renders beside the member the caller gave, with class
8
+ # merging on or off:
9
+ #
10
+ # 1. the caller's members (the last one per group, as ClassMerge keeps)
11
+ # 2. defaults, for groups the caller left empty
12
+ # 3. compounds in declaration order; each one's `set` fills or replaces a
13
+ # member the caller did not name, and later compounds see it
14
+ # 4. `on` rules, against the final selection
15
+ #
16
+ # @api private
17
+ class Resolution
18
+ attr_reader :compounds, :rules
19
+
20
+ def initialize(klass, given)
21
+ definitions = klass.variant_definitions
22
+ @given = given
23
+ @selection = klass.modifier_groups.to_h { |group, members| [group, given.reverse.find { |m| members.include?(m) }] }
24
+ @named = @selection.compact.keys
25
+ definitions[:defaults].each { |group, member| @selection[group] ||= member }
26
+ @compounds = matching_compounds(definitions[:compounds])
27
+ @rules = definitions[:rules].select { |rule| holds?(rule[:match]) }
28
+ end
29
+
30
+ # Members a default or a compound selected, not the caller.
31
+ def implied_members
32
+ @selection.filter_map { |group, member| member unless @named.include?(group) }
33
+ end
34
+
35
+ # Every member and flag in effect, for the slots' per-member classes.
36
+ def active = (@given | implied_members)
37
+
38
+ private
39
+
40
+ # One at a time: a compound's `set` is seen by the compounds after it.
41
+ def matching_compounds(compounds)
42
+ compounds.each_with_object([]) do |compound, matched|
43
+ next unless holds?(compound[:match])
44
+
45
+ matched << compound
46
+ compound[:set].each { |group, member| @selection[group] = member unless @named.include?(group) }
47
+ end
48
+ end
49
+
50
+ def holds?(match) = Match.holds?(match, @selection, @given)
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DaisyUI
4
+ class Variants
5
+ # `Component.variants.schema`: a component's declarations as plain,
6
+ # frozen data. A component that only calls `register_modifiers` gets its
7
+ # variants and flags, with the rest empty.
8
+ #
9
+ # @api private
10
+ class Schema
11
+ # Ungrouped modifiers and the flags registered without a class.
12
+ def self.flags(klass)
13
+ grouped = klass.modifier_groups.values.flatten
14
+ ((klass.modifiers || {}).keys - grouped) | klass.flags
15
+ end
16
+
17
+ def initialize(klass)
18
+ @klass = klass
19
+ @definitions = klass.variant_definitions
20
+ end
21
+
22
+ def to_h
23
+ Shareable.share(
24
+ base:,
25
+ variants: @klass.modifier_groups.transform_values(&:dup),
26
+ flags: self.class.flags(@klass),
27
+ defaults: @definitions[:defaults].dup,
28
+ compounds: @definitions[:compounds].map { |entry| { match: entry[:match], class: shown(entry[:class]), set: entry[:set] } },
29
+ on: @definitions[:rules].map { |entry| { states: entry[:states], match: entry[:match], class: shown(entry[:class]) } },
30
+ states: @definitions[:states].dup,
31
+ slots: { root: base, **@definitions[:slots] }
32
+ )
33
+ end
34
+
35
+ # Every class token the declarations can render, `prefix` included the
36
+ # way Base#apply_prefix adds it (after any state), sorted.
37
+ def literal_classes(prefix)
38
+ plain = [base, *modifier_classes, *@definitions[:slots].values, *slot_member_classes, *compound_classes]
39
+ plain = plain.compact.flat_map(&:split).map { |token| "#{prefix}#{token}" }
40
+ (plain + state_classes(prefix)).uniq.sort
41
+ end
42
+
43
+ private
44
+
45
+ def base
46
+ classes = [@klass.component_class&.to_s, @definitions[:base]].compact.join(" ")
47
+ classes.empty? ? nil : classes
48
+ end
49
+
50
+ def modifier_classes = (@klass.modifiers || {}).values
51
+
52
+ def slot_member_classes = @definitions[:slot_classes].values.flat_map(&:values)
53
+
54
+ def compound_classes = @definitions[:compounds].flat_map { |entry| entry[:class].values }
55
+
56
+ def state_classes(prefix)
57
+ @definitions[:rules].flat_map do |rule|
58
+ tokens = rule[:class].values.flat_map(&:split)
59
+ rule[:states].flat_map do |state|
60
+ tokens.map { |token| "#{@definitions[:states].fetch(state)}:#{prefix}#{token}" }
61
+ end
62
+ end
63
+ end
64
+
65
+ # A root-only class shows as its String, per-slot classes as the Hash.
66
+ def shown(classes)
67
+ return nil if classes.empty?
68
+
69
+ classes.keys == [:root] ? classes[:root] : classes
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DaisyUI
4
+ # The declarative layer over `register_modifiers`: compound looks, defaults,
5
+ # browser states and parts (slots), declared once in the class body.
6
+ #
7
+ # class Widget < DaisyUI::Button
8
+ # variants do
9
+ # base "font-medium"
10
+ # variant :size, sm: "btn-sm [--fontsize:var(--text-sm)]"
11
+ # default :size, :md
12
+ # compound style: :outline, color: nil, class: "[--btn-bg:var(--color-base-100)]"
13
+ # state :hover, "hover"
14
+ # on :hover, style: %i[ghost link], class: "[--btn-bg:transparent]"
15
+ # end
16
+ # end
17
+ #
18
+ # `variant` and `flag` write through `register_modifiers` (so ClassMerge
19
+ # groups, inheritance and finalize! stay as they are); everything else lands
20
+ # in the class's `variant_definitions`, a frozen Hash replaced on every
21
+ # write. `Widget.variants.schema` describes it all as plain data. Matching,
22
+ # validation and rendering live in Variants::Match, Variants::Resolution and
23
+ # Variants::Rendering.
24
+ class Variants
25
+ EMPTY = Shareable.make(
26
+ { base: nil, defaults: {}, compounds: [], rules: [], states: {}, slots: {}, slot_classes: {} }
27
+ )
28
+ STATE_PREFIX = /\A[^\s"'`;{}\\]+\z/
29
+
30
+ # The DSL for `klass`, with `block` evaluated against it.
31
+ def self.build(klass, &block)
32
+ new(klass).tap { |dsl| dsl.instance_exec(&block) if block }
33
+ end
34
+
35
+ def initialize(klass)
36
+ @klass = klass
37
+ end
38
+
39
+ # Classes every instance gets, after the inherited base.
40
+ def base(classes)
41
+ write(base: [definitions[:base], classes].compact.join(" "))
42
+ end
43
+
44
+ # A group of alternatives: `variant :size, sm: "btn-sm", lg: "btn-lg"`. A
45
+ # Hash value gives classes per slot (`sm: { root: "…", menu: "…" }`).
46
+ def variant(group, **members)
47
+ register(group => members.transform_values { |classes| root_classes(classes) })
48
+ members.each { |member, classes| write_slot_classes(member, classes) }
49
+ self
50
+ end
51
+
52
+ # An on/off modifier: `flag :wide, "btn-wide"`, or a flag with no class of
53
+ # its own (`flag :collapsed`) that compounds and `on` rules can match.
54
+ def flag(name, classes = nil)
55
+ return tap { @klass.register_flags(name) } if classes.nil?
56
+
57
+ register(name => root_classes(classes))
58
+ write_slot_classes(name, classes)
59
+ self
60
+ end
61
+
62
+ # The member used when the caller names no member of `group`.
63
+ def default(group, member)
64
+ check.member!(group, member)
65
+ write(defaults: definitions[:defaults].merge(group => member))
66
+ end
67
+
68
+ # Adds `class:` and sets the variants in `set:` (members the caller did
69
+ # not name) when every key of `match` holds. See Variants::Match.
70
+ def compound(**match)
71
+ classes = match.delete(:class)
72
+ set = match.delete(:set) || {}
73
+ raise ArgumentError, "#{owner}: compound #{match.inspect} needs class: or set:" if classes.nil? && set.empty?
74
+
75
+ check.match!(match)
76
+ set.each { |group, member| check.member!(group, member) }
77
+ add(:compounds, match:, class: slot_hash(classes), set:)
78
+ end
79
+
80
+ # Names a Tailwind variant prefix once: `state :open, "[.dropdown[open]>&]"`.
81
+ def state(name, prefix)
82
+ raise ArgumentError, "#{owner}: state #{name.inspect} needs a Tailwind variant, got #{prefix.inspect}" unless STATE_PREFIX.match?(prefix.to_s)
83
+
84
+ write(states: definitions[:states].merge(name => prefix.to_s))
85
+ end
86
+
87
+ # Like compound, with every class prefixed by each of the `states`.
88
+ def on(states, **match)
89
+ states = Array(states)
90
+ classes = match.delete(:class)
91
+ raise ArgumentError, "#{owner}: on #{states.inspect} needs class:" if classes.nil?
92
+
93
+ states.each { |name| check.state!(name) }
94
+ check.match!(match)
95
+ add(:rules, states:, match:, class: slot_hash(classes))
96
+ end
97
+
98
+ # A part of the component (`root` is the component itself), with the
99
+ # classes it always gets. Read with Base#slot_classes / #slot_options.
100
+ def slot(name, classes = nil)
101
+ raise ArgumentError, "#{owner}: :root is the component itself, not a slot to declare" if name == :root
102
+
103
+ write(slots: definitions[:slots].merge(name => classes))
104
+ end
105
+
106
+ # Plain, frozen data describing the component, for docs pages, galleries
107
+ # and design-tool parity checks.
108
+ def schema
109
+ Schema.new(@klass).to_h
110
+ end
111
+
112
+ # Every class the component's declarations can render, prefixed as
113
+ # DaisyUI.configuration.prefix renders them: what Tailwind needs to see.
114
+ def literal_classes(prefix: nil)
115
+ Schema.new(@klass).literal_classes(prefix.to_s)
116
+ end
117
+
118
+ private
119
+
120
+ def definitions = @klass.variant_definitions
121
+
122
+ def owner = @klass.name || @klass.inspect
123
+
124
+ def check = Match.new(@klass)
125
+
126
+ def write(**changes)
127
+ @klass.variant_definitions = definitions.merge(changes)
128
+ self
129
+ end
130
+
131
+ def add(key, **entry)
132
+ write(key => [*definitions[key], entry])
133
+ end
134
+
135
+ def register(modifiers)
136
+ @klass.register_modifiers(modifiers)
137
+ end
138
+
139
+ def root_classes(classes)
140
+ classes.is_a?(Hash) ? slot_hash(classes)[:root].to_s : classes.to_s
141
+ end
142
+
143
+ # Slot parts of a member, kept apart from `modifiers`, which only ever
144
+ # holds the root's String.
145
+ def write_slot_classes(member, classes)
146
+ parts = classes.is_a?(Hash) ? slot_hash(classes).except(:root) : {}
147
+ slot_classes = definitions[:slot_classes].except(member)
148
+ write(slot_classes: parts.empty? ? slot_classes : slot_classes.merge(member => parts))
149
+ end
150
+
151
+ # `"a b"` or `{ root: "a", menu: "b" }` as `{ slot => classes }`.
152
+ def slot_hash(classes)
153
+ return {} if classes.nil?
154
+ return { root: classes.to_s } unless classes.is_a?(Hash)
155
+
156
+ classes.each_key { |slot| check.slot!(slot) }
157
+ classes.transform_values(&:to_s)
158
+ end
159
+ end
160
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DaisyUI
4
- VERSION = "2.1.1"
4
+ VERSION = "2.2.0"
5
5
  end
data/lib/daisy_ui.rb CHANGED
@@ -14,6 +14,8 @@ loader.ignore("#{__dir__}/daisy_ui/updated_at.rb")
14
14
  # The Rails engine references Rails::Engine, so it is required explicitly below
15
15
  # (only when Rails is present) rather than autoloaded.
16
16
  loader.ignore("#{__dir__}/daisy_ui/engine.rb")
17
+ # lib/tasks holds the engine's rake tasks, no constants.
18
+ loader.ignore("#{__dir__}/tasks")
17
19
  loader.setup # ready!
18
20
  loader.load_file("#{__dir__}/daisy_ui/base.rb")
19
21
 
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Loaded by the Rails engine (Rails loads an engine's lib/tasks/*.rake).
4
+ namespace :daisyui do
5
+ desc "Write the Tailwind sources of the app's own DaisyUI components to OUTPUT (stdout without one)"
6
+ task :tailwind, [:output] => :environment do |_task, args|
7
+ argv = ["--app"]
8
+ argv.push("--output", args[:output]) if args[:output]
9
+ status = DaisyUI::Tailwind::Command.run(argv)
10
+ exit(status) unless status.zero?
11
+ end
12
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: daisyui
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.1
4
+ version: 2.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -128,6 +128,7 @@ files:
128
128
  - lib/daisy_ui/loading.rb
129
129
  - lib/daisy_ui/mask.rb
130
130
  - lib/daisy_ui/mcp_server.rb
131
+ - lib/daisy_ui/mcp_server/variants_guide.rb
131
132
  - lib/daisy_ui/megamenu.rb
132
133
  - lib/daisy_ui/menu.rb
133
134
  - lib/daisy_ui/menu_item.rb
@@ -161,6 +162,7 @@ files:
161
162
  - lib/daisy_ui/table_row.rb
162
163
  - lib/daisy_ui/tabs.rb
163
164
  - lib/daisy_ui/tailwind.rb
165
+ - lib/daisy_ui/tailwind/app_sources.rb
164
166
  - lib/daisy_ui/tailwind/candidate.rb
165
167
  - lib/daisy_ui/tailwind/command.rb
166
168
  - lib/daisy_ui/tailwind/lexer.rb
@@ -179,8 +181,14 @@ files:
179
181
  - lib/daisy_ui/unknown_modifier_error.rb
180
182
  - lib/daisy_ui/updated_at.rb
181
183
  - lib/daisy_ui/validator.rb
184
+ - lib/daisy_ui/variants.rb
185
+ - lib/daisy_ui/variants/match.rb
186
+ - lib/daisy_ui/variants/rendering.rb
187
+ - lib/daisy_ui/variants/resolution.rb
188
+ - lib/daisy_ui/variants/schema.rb
182
189
  - lib/daisy_ui/version.rb
183
190
  - lib/daisyui.rb
191
+ - lib/tasks/daisyui.rake
184
192
  homepage: https://github.com/mhenrixon/daisyui
185
193
  licenses:
186
194
  - MIT