Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Replace Tails by turboprop #84

Merged
merged 3 commits into from
Nov 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 9 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,20 @@

## Installation

1. **Using `salad_ui` as part of your project:**

> This way you can install only components that you want to use or you want to edit SaladUI's component source code to fit your need.
> If you just want to use SaladUI's components, see **Using as library** below.

- Adding `salad_ui` to your list of dependencies in `mix.exs`:

1. Add `salad_ui` to your `mix.exs`
```elixir
def deps do
[
{:salad_ui, "~> 0.13.0", only: [:dev]},
{:tails, "~> 0.1"}
{:salad_ui, "~> 0.13.0"},
]
end
```

2. **Using `salad_ui` as part of your project:**

> This way you can install only components that you want to use or you want to edit SaladUI's component source code to fit your need.
> If you just want to use SaladUI's components, see **Using as library** below.

- Init Salad UI in your project
```
#> cd your_project
Expand All @@ -52,18 +50,7 @@ end
#> mix salad.add label button
```

2. **Using `salad_ui` as a library:**

- Adding `salad_ui` to your list of dependencies in `mix.exs`:

```elixir
def deps do
[
{:salad_ui, "~> 0.13.0", only: [:dev]}
]
end
```

3. **Using `salad_ui` as a library:**
Comment on lines 50 to +53
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (documentation): Installation methods could be more clearly distinguished

Consider adding a brief comparison of when to use each installation method at the start of the installation section to help users choose the appropriate approach.

## Installation

Choose your installation method:
- **As a library**: Use this if you want to use pre-built components without modifications
- **As a project dependency**: Use this if you need to customize components or use specific ones

2. **Using `salad_ui` as part of your project:**

> Install only the components you need and customize the source code to fit your requirements.

- Init Salad UI in your project

- Init Salad UI in your project with option `--as-lib`
```
#> cd your_project
Expand Down Expand Up @@ -169,6 +156,6 @@ To run the failing tests only, just run `mix test.watch --stale`.
This project could not be available without these awesome works:

- `tailwind css` an awesome css utility project
- `tails` for merging tailwind class
- `turboprop` I borrow code from here for merging tailwinds classes
- `shadcn/ui` which this project is inspired from
- `Phoenix Framework` of course
2 changes: 0 additions & 2 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,3 @@ config :tailwind,
),
cd: Path.expand("../assets", __DIR__)
]

config :tails, colors_file: Path.join(File.cwd!(), "assets/tailwind.colors.json")
14 changes: 2 additions & 12 deletions docs/manual_install.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,17 +74,7 @@ npm i -D tailwindcss-animate
yarn add -D tailwindcss-animate
```

3. Configure `tails`
SaladUI use `tails` to properly merge Tailwindcss classes

```elixir
# config/config.exs

config :tails, colors_file: Path.join(File.cwd!(), "assets/tailwind.colors.json")
```


4. **Add javascript to handle event from server**
3. **Add javascript to handle event from server**
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (documentation): Step numbering needs to be updated after removal of tails section

The steps go from 2 to 4, skipping 3. Please update the numbering to be sequential.

This add ability to execute client action from server. It's similar to `JS.exec/2`. Thanks to [this post](https://fly.io/phoenix-files/server-triggered-js/) from fly.io.

Add this code snippet to the end of `app.js`
Expand All @@ -106,7 +96,7 @@ Then from server side, you can close an opening sheet like this.
end
```

5. Some tweaks
4. Some tweaks
Thanks to @ahacking

- To make dark and light mode work correctly, add following to your `app.css`
Expand Down
18 changes: 1 addition & 17 deletions lib/mix/tasks/salad.init.ex
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,7 @@ defmodule Mix.Tasks.Salad.Init do
end

defp write_config(component_path) do
with :ok <- write_dev_config(component_path) do
write_tails_config()
end
write_dev_config(component_path)
end

defp write_dev_config(component_path) do
Expand All @@ -110,20 +108,6 @@ defmodule Mix.Tasks.Salad.Init do
patch_config(dev_config_path, components_config)
end

defp write_tails_config do
Mix.shell().info("Writing tails config to config.exs")
config_path = Path.join(File.cwd!(), "config/config.exs")

tails_config = [
tails: %{
description: "SaladUI use tails to properly merge Tailwind CSS classes",
values: [colors_file: "Path.join(File.cwd!(), \"assets/tailwind.colors.json\")"]
}
]

patch_config(config_path, tails_config)
end

defp patch_config(config_path, config) do
if File.exists?(config_path) do
Patcher.patch_config(config_path, config)
Expand Down
4 changes: 3 additions & 1 deletion lib/salad_ui.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ defmodule SaladUI do
use Phoenix.Component

import SaladUI.Helpers
import Tails, only: [classes: 1]

# alias OrangeCmsWeb.Components.LadUI.LadJS
alias Phoenix.LiveView.JS
defp classes(input) do
SaladUI.Merge.merge(input)
end
end
end

Expand Down
44 changes: 44 additions & 0 deletions lib/utils/cache.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
defmodule SaladUI.Cache do
@moduledoc false
use GenServer

alias SaladUI.Merge.ClassTree

@default_table_name :turboprop_cache

@doc false
def default_table_name, do: @default_table_name

def start_link(default) when is_list(default) do
GenServer.start_link(__MODULE__, default)
end

@impl true
def init(opts \\ []) do
table_name = Keyword.get(opts, :cache_table_name, @default_table_name)
create_table(table_name)

insert(:class_tree, ClassTree.generate())

{:ok, []}
end

def create_table(table_name \\ @default_table_name) do
:ets.new(table_name, [:set, :public, :named_table, read_concurrency: true])
end

def insert(key, value, table_name \\ @default_table_name) do
:ets.insert(table_name, {key, value})
end

def retrieve(key, table_name \\ @default_table_name) do
case :ets.lookup(table_name, key) do
[{^key, value}] -> value
[] -> nil
end
end

def purge(table_name \\ @default_table_name) do
:ets.delete_all_objects(table_name)
end
end
141 changes: 141 additions & 0 deletions lib/utils/merge.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
defmodule SaladUI.Merge do
@moduledoc """
SaladUI Merge adds efficient class joining and merging of TailwindCSS classes to Elixir.

TailwindCSS class names are composable and allow specifying an infinite amount of different styles. Most components allow overriding class
names, like as passing `class` attribute that then gets merged with the existing styles. This can result in class lists such as
`text-white bg-red-500 bg-blue-300` where `text-white bg-red-500` is the preset style, and `bg-blue-300` is the override for that one
specific button that needs to look slightly different.
Styles based on class are applied according to the order _they are defined at in the stylesheet_. In this example, because TailwindCSS
orders color definitions alphabetically, the override does not work. `blue` is defined before `red`, so the `bg-red-500` class takes
precedence since it was defined later.

In order to still allow overriding styles, SaladUI Merge traverses the entire class list, creates a list of all classes and which
conflicting groups of styles exist in them and gives precedence to the ones that were defined last _in the class list_, which, unlike the
stylesheet, is in control of the user.


## Example

```elixir
iex> merge("text-white bg-red-500 bg-blue-300")
"text-white bg-blue-300"

iex> merge(["px-2 py-1 bg-red hover:bg-dark-red", "p-3 bg-[#B91C1C]"])
"hover:bg-dark-red p-3 bg-[#B91C1C]"
```

## Configuration

SaladUI Merge does not currently support full theme configuration - that's on the roadmap!

The limited configuration at the moment is adding Tailwind's `prefix` option.

```elixir
config :turboprop,
prefix: "tw-"
```
"""

alias SaladUI.Cache
alias SaladUI.Merge.Class
alias SaladUI.Merge.Config

@doc """
Joins and merges a list of classes.

Passes the input to `join/1` before merging.
"""
@spec merge(list(), term()) :: binary()
def merge(input, config \\ Config.config()) do
input
|> join()
|> retrieve_from_cache_or_merge(config)
end

@doc """
Joins a list of classes.
"""
@spec merge(binary() | list()) :: binary()
def join(input) when is_binary(input), do: input
def join(input) when is_list(input), do: do_join(input, "")
def join(_), do: ""

defp do_join("", result), do: result
defp do_join(nil, result), do: result
defp do_join([], result), do: result

defp do_join(string, result) when is_binary(string), do: do_join([string], result)

defp do_join([head | tail], result) do
case to_value(head) do
"" -> do_join(tail, result)
value when result == "" -> do_join(tail, value)
value -> do_join(tail, result <> " " <> value)
end
end

defp to_value(value) when is_binary(value), do: value

defp to_value(values) when is_list(values) do
Enum.reduce(values, "", fn v, acc ->
case to_value(v) do
"" -> acc
resolved_value when acc == "" -> resolved_value
resolved_value -> acc <> " " <> resolved_value
end
end)
end

defp to_value(_), do: ""

defp retrieve_from_cache_or_merge(classes, config) do
case Cache.retrieve("merge:#{classes}") do
nil ->
merged_classes = do_merge(classes, config)
Cache.insert("merge:#{classes}", merged_classes)
merged_classes

merged_classes ->
merged_classes
end
end

defp do_merge(classes, config) do
classes
|> String.trim()
|> String.split(~r/\s+/)
|> Enum.map(&Class.parse/1)
|> Enum.reverse()
|> Enum.reduce(%{classes: [], groups: []}, fn class, acc ->
handle_class(class, acc, config)
end)
|> Map.get(:classes)
|> Enum.join(" ")
end

defp handle_class(%{raw: raw, tailwind?: false}, acc, _config), do: Map.update!(acc, :classes, fn classes -> [raw | classes] end)

defp handle_class(%{conflict_id: conflict_id} = class, acc, config) do
if Enum.member?(acc.groups, conflict_id), do: acc, else: add_class(acc, class, config)
end

defp add_class(acc, %{raw: raw, group: group, conflict_id: conflict_id, modifier_id: modifier_id}, config) do
conflicting_groups =
group
|> conflicting_groups(config)
|> Enum.map(&"#{modifier_id}:#{&1}")
|> then(&[conflict_id | &1])

acc
|> Map.update!(:classes, fn classes -> [raw | classes] end)
|> Map.update!(:groups, fn groups -> groups ++ conflicting_groups end)
end

defp conflicting_groups(group, config) do
conflicts = Map.get(config.conflicting_groups, group, [])
modifier_conflicts = Map.get(config.conflicting_group_modifiers, group, [])

conflicts ++ modifier_conflicts
end
end
Loading
Loading