daisyui 2.1.0 → 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 +4 -4
- data/CHANGELOG.md +168 -23
- data/README.md +266 -20
- data/data/daisyui/tailwind_manifest.json +2278 -0
- data/exe/daisyui-tailwind +6 -0
- data/lib/daisy_ui/base.rb +24 -4
- data/lib/daisy_ui/engine.rb +4 -0
- data/lib/daisy_ui/mcp_server/variants_guide.rb +99 -0
- data/lib/daisy_ui/mcp_server.rb +16 -1
- data/lib/daisy_ui/tailwind/app_sources.rb +73 -0
- data/lib/daisy_ui/tailwind/candidate.rb +44 -0
- data/lib/daisy_ui/tailwind/command.rb +102 -0
- data/lib/daisy_ui/tailwind/lexer.rb +31 -0
- data/lib/daisy_ui/tailwind/manifest.rb +59 -0
- data/lib/daisy_ui/tailwind/manifest_builder.rb +86 -0
- data/lib/daisy_ui/tailwind/sources.rb +106 -0
- data/lib/daisy_ui/tailwind/unknown_component_error.rb +17 -0
- data/lib/daisy_ui/tailwind/usage.rb +72 -0
- data/lib/daisy_ui/tailwind.rb +91 -0
- data/lib/daisy_ui/unknown_modifier_error.rb +9 -2
- data/lib/daisy_ui/updated_at.rb +1 -1
- data/lib/daisy_ui/variants/match.rb +89 -0
- data/lib/daisy_ui/variants/rendering.rb +68 -0
- data/lib/daisy_ui/variants/resolution.rb +53 -0
- data/lib/daisy_ui/variants/schema.rb +73 -0
- data/lib/daisy_ui/variants.rb +160 -0
- data/lib/daisy_ui/version.rb +1 -1
- data/lib/daisy_ui.rb +2 -0
- data/lib/tasks/daisyui.rake +12 -0
- metadata +21 -1
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DaisyUI
|
|
4
|
+
module Tailwind
|
|
5
|
+
# Renders the Tailwind sources for a set of (resolved) components:
|
|
6
|
+
#
|
|
7
|
+
# - `to_css`: a partial for the app's Tailwind entry to @import, one
|
|
8
|
+
# `@source inline("…")` per component after base.rb's shared classes. It
|
|
9
|
+
# names no path, so it is the same on every machine.
|
|
10
|
+
# - `to_json`: the same for a build script, plus each component's absolute
|
|
11
|
+
# files for anyone who wants a literal `@source` instead.
|
|
12
|
+
#
|
|
13
|
+
# `responsive:` adds `<breakpoint>:` variants of the classes `responsive:`
|
|
14
|
+
# renders (a component's own class and its modifiers). `prefix:` mirrors
|
|
15
|
+
# DaisyUI.configuration.prefix: the component class and modifiers always
|
|
16
|
+
# render prefixed, while a class string in a component's Ruby may render
|
|
17
|
+
# either way (`component_classes("card-body")` is prefixed, Tooltip's
|
|
18
|
+
# "after:hidden" is not), so those are given both ways.
|
|
19
|
+
class Sources
|
|
20
|
+
BREAKPOINT = /\A@?[a-z0-9][a-z0-9-]*\z/
|
|
21
|
+
PREFIX = /\A[^\s"'`;{}\\]+\z/
|
|
22
|
+
|
|
23
|
+
attr_reader :components
|
|
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
|
+
|
|
35
|
+
def initialize(manifest, components, responsive: [], prefix: nil)
|
|
36
|
+
@manifest = manifest
|
|
37
|
+
@components = components
|
|
38
|
+
@breakpoints = responsive.map(&:to_s)
|
|
39
|
+
@prefix = prefix.to_s
|
|
40
|
+
validate!
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def render(format)
|
|
44
|
+
case format.to_s
|
|
45
|
+
when "css" then to_css
|
|
46
|
+
when "json" then to_json
|
|
47
|
+
else raise ArgumentError, "unknown format #{format.inspect}: css or json"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def to_css
|
|
52
|
+
header = "/* Generated by daisyui-tailwind (daisyui #{VERSION}): do not edit. */\n"
|
|
53
|
+
return "#{header}/* No DaisyUI components referenced. */\n" if components.empty?
|
|
54
|
+
|
|
55
|
+
lines = [
|
|
56
|
+
"/* daisyUI components: #{components.join(', ')} */",
|
|
57
|
+
"/* Shared by every component (lib/daisy_ui/base.rb) */",
|
|
58
|
+
inline(shared_classes)
|
|
59
|
+
]
|
|
60
|
+
components.each { |name| lines.push("/* #{@manifest.components.fetch(name)['class']} */", inline(classes(name))) }
|
|
61
|
+
"#{header}#{lines.join("\n")}\n"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def to_json(*)
|
|
65
|
+
require "json"
|
|
66
|
+
|
|
67
|
+
"#{JSON.pretty_generate(json)}\n"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def json
|
|
73
|
+
files = @manifest.shared_files + @manifest.files(components)
|
|
74
|
+
{
|
|
75
|
+
"daisyui" => VERSION,
|
|
76
|
+
"components" => components.to_h { |name| [name, component_json(name)] },
|
|
77
|
+
"shared" => { "files" => @manifest.shared_files, "classes" => shared_classes },
|
|
78
|
+
"classes" => (shared_classes + components.flat_map { |name| classes(name) }).uniq.sort,
|
|
79
|
+
"files" => files.uniq.sort
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def component_json(name)
|
|
84
|
+
entry = @manifest.components.fetch(name)
|
|
85
|
+
entry.slice("class", "name", "depends_on").merge("files" => @manifest.files([name]), "classes" => classes(name))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def classes(name)
|
|
89
|
+
entry = @manifest.components.fetch(name)
|
|
90
|
+
expand(entry["classes"], [entry["name"], *entry["modifiers"]].compact)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def shared_classes = expand(@manifest.shared["classes"], @manifest.shared["classes"])
|
|
94
|
+
|
|
95
|
+
def expand(classes, responsive)
|
|
96
|
+
prefixed = classes.map { |token| "#{@prefix}#{token}" }
|
|
97
|
+
prefixed += classes - responsive unless @prefix.empty?
|
|
98
|
+
prefixed + @breakpoints.flat_map { |breakpoint| responsive.map { |token| "#{breakpoint}:#{@prefix}#{token}" } }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def inline(classes) = %(@source inline("#{classes.join(' ')}");)
|
|
102
|
+
|
|
103
|
+
def validate! = self.class.validate_options!(@breakpoints, @prefix)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "did_you_mean"
|
|
4
|
+
|
|
5
|
+
module DaisyUI
|
|
6
|
+
module Tailwind
|
|
7
|
+
# A component name (`--component Buton`) the manifest does not have.
|
|
8
|
+
class UnknownComponentError < ArgumentError
|
|
9
|
+
def initialize(name, known)
|
|
10
|
+
suggestions = DidYouMean::SpellChecker.new(dictionary: known).correct(name.to_s)
|
|
11
|
+
message = "DaisyUI has no component #{name}"
|
|
12
|
+
message = "#{message}. Did you mean? #{suggestions.join(', ')}" unless suggestions.empty?
|
|
13
|
+
super(message)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DaisyUI
|
|
4
|
+
module Tailwind
|
|
5
|
+
# Finds the components an app's Ruby and templates reference:
|
|
6
|
+
#
|
|
7
|
+
# - the constant: `DaisyUI::Card`, `class AppNavbar < DaisyUI::Navbar`
|
|
8
|
+
# - an instance: `Card.new(…)`
|
|
9
|
+
# - a kit call, with parentheses (`Button(:primary)`) or without
|
|
10
|
+
# (`Badge :primary do`, `Hero do`, `Modal { … }`, `Toast class: "…"`,
|
|
11
|
+
# `Button variant do`)
|
|
12
|
+
#
|
|
13
|
+
# Comments are skipped (each file's own kind: `#` lines in Ruby, `<%# %>`
|
|
14
|
+
# in ERB, `-#` in HAML, `/` in Slim), so "# Loading state…" is not a use
|
|
15
|
+
# of Loading, while a HAML `#sidebar= …` line still counts. A name is matched whole: `MyButton(` and `UI::Card(` are not
|
|
16
|
+
# uses (an app wrapper `UI::Card < DaisyUI::Card` is found by its own
|
|
17
|
+
# file). Matching on text over-reports rather than misses: a false hit
|
|
18
|
+
# costs some CSS, a miss leaves a component unstyled.
|
|
19
|
+
class Usage
|
|
20
|
+
COMMENTS = {
|
|
21
|
+
"rb" => /^[ \t]*#.*$/,
|
|
22
|
+
"erb" => /<%#.*?%>/m,
|
|
23
|
+
"haml" => /^[ \t]*-#.*$/,
|
|
24
|
+
"slim" => %r{^[ \t]*/.*$}
|
|
25
|
+
}.freeze
|
|
26
|
+
EXTENSIONS = COMMENTS.keys.freeze
|
|
27
|
+
# What follows a name in a call: `(`, `.new`, a block, or arguments
|
|
28
|
+
# without parentheses: `:primary`, `class: …`, `"…"`, a splat
|
|
29
|
+
# (`*modifiers`, `**options`, `&block`), or a variable that ends the line
|
|
30
|
+
# or is followed by `,`, `do` or `{` (`Button variant do`), which keeps
|
|
31
|
+
# "Join thousands of…" in a string from counting.
|
|
32
|
+
CALL = /
|
|
33
|
+
\( | \.new\b | [ \t]*\{ |
|
|
34
|
+
[ \t]+ (?: do\b | :[a-z_] | ["'] | [a-z_]\w*:[ \t] | (?: \*\*? | & ) [a-z_@(] |
|
|
35
|
+
[a-z_@][\w@]* (?: \.[a-z_]\w* )* [ \t]* (?: , | do\b | \{ | $ ) )
|
|
36
|
+
/x
|
|
37
|
+
|
|
38
|
+
def initialize(names)
|
|
39
|
+
names = names.sort_by { |name| -name.length }.map { |name| Regexp.escape(name) }.join("|")
|
|
40
|
+
@pattern = /DaisyUI::(#{names})\b|(?<![A-Za-z0-9_:])(#{names})(?=#{CALL})/
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Sorted names referenced in the files under `paths` (directories or
|
|
44
|
+
# files). A path that does not exist raises: a typo must not quietly
|
|
45
|
+
# leave every component out.
|
|
46
|
+
def scan(paths)
|
|
47
|
+
files(paths).flat_map { |file| references(File.read(file).scrub, kind: File.extname(file).delete_prefix(".")) }
|
|
48
|
+
.uniq.sort
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Sorted names referenced in `source`, a file of `kind` (its extension).
|
|
52
|
+
def references(source, kind: "rb")
|
|
53
|
+
source.gsub(COMMENTS.fetch(kind, COMMENTS["rb"]), "").scan(@pattern).flat_map(&:compact).uniq.sort
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def files(paths)
|
|
59
|
+
paths.flat_map do |path|
|
|
60
|
+
path = path.to_s
|
|
61
|
+
if File.directory?(path)
|
|
62
|
+
Dir.glob(File.join(path, "**", "*.{#{EXTENSIONS.join(',')}}"))
|
|
63
|
+
elsif File.file?(path)
|
|
64
|
+
[path]
|
|
65
|
+
else
|
|
66
|
+
raise ArgumentError, "no such file or directory: #{path}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DaisyUI
|
|
4
|
+
# Tailwind CSS sources for only the daisyUI components an app renders.
|
|
5
|
+
#
|
|
6
|
+
# Pointing Tailwind's `@source` at the whole gem hands it every class of
|
|
7
|
+
# every component (and ClassMerge's catalogue of Tailwind utilities), yet
|
|
8
|
+
# still misses what Tailwind v4 cannot see in Ruby: a Symbol component class
|
|
9
|
+
# (`self.component_class = :btn`) and anything in a comment. The gem's
|
|
10
|
+
# manifest (data/daisyui/tailwind_manifest.json, built from the components by
|
|
11
|
+
# `rake tailwind:manifest`) lists each component's classes and the
|
|
12
|
+
# components it renders, so an app's CSS build gets `@source inline(…)` for
|
|
13
|
+
# just the components it uses:
|
|
14
|
+
#
|
|
15
|
+
# DaisyUI::Tailwind.sources(scan: ["app/views", "app/components"])
|
|
16
|
+
# DaisyUI::Tailwind.sources(components: %w[Button Navbar], format: :json)
|
|
17
|
+
#
|
|
18
|
+
# `bundle exec daisyui-tailwind` (DaisyUI::Tailwind::Command) does the same
|
|
19
|
+
# from a build script.
|
|
20
|
+
module Tailwind
|
|
21
|
+
ROOT = File.expand_path("../..", __dir__).freeze
|
|
22
|
+
# In the gem's data/ dir, not lib/: an app that still points Tailwind at
|
|
23
|
+
# the gem's lib/ must not start scanning it.
|
|
24
|
+
MANIFEST_PATH = File.join(ROOT, "data", "daisyui", "tailwind_manifest.json").freeze
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
def manifest = Manifest.load
|
|
28
|
+
|
|
29
|
+
# The components the Ruby and templates under `scan` reference, plus the
|
|
30
|
+
# `components` named, plus every component those render. Sorted names.
|
|
31
|
+
def used_components(scan: [], components: [], manifest: self.manifest)
|
|
32
|
+
found = scan.empty? ? [] : Usage.new(manifest.component_names).scan(scan)
|
|
33
|
+
manifest.resolve(found + components)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# CSS (`format: :css`, for Tailwind to @import) or JSON for those
|
|
37
|
+
# components. `responsive:` adds breakpoint variants (`%w[sm md]`) of the
|
|
38
|
+
# classes `responsive:` renders; `prefix:` defaults to the
|
|
39
|
+
# DaisyUI.configuration.prefix the components render with.
|
|
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
|
+
|
|
47
|
+
manifest = self.manifest
|
|
48
|
+
used = used_components(scan:, components:, manifest:)
|
|
49
|
+
Sources.new(manifest, used, responsive:, prefix:).render(format)
|
|
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
|
|
89
|
+
end
|
|
90
|
+
end
|
|
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
|
-
|
|
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
|
-
|
|
35
|
+
component.modifier_groups.map { |group, members| "#{group}: #{members.join(', ')}" }.join("; ")
|
|
29
36
|
end
|
|
30
37
|
end
|
|
31
38
|
end
|
data/lib/daisy_ui/updated_at.rb
CHANGED
|
@@ -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
|