diff --git a/assets/css/app.css b/assets/css/app.css index 5c5d07f..7e5fbd6 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -75,6 +75,52 @@ --ring: oklch(0.556 0 0); } +/* Flexoki syntax highlighting — https://stephango.com/flexoki + Token classes are Makeup's (Pygments short names); colors are Flexoki's, + using the 600 accents on light and the 400 accents on dark. */ +.highlight { + --hl-comment: #6f6e69; /* base-600 */ + --hl-punct: #6f6e69; /* base-600 */ + --hl-keyword: #66800b; /* green-600 */ + --hl-string: #24837b; /* cyan-600 */ + --hl-function: #bc5215; /* orange-600 */ + --hl-attr: #205ea6; /* blue-600 */ + --hl-number: #5e409d; /* purple-600 */ + --hl-constant: #ad8301; /* yellow-600 */ + --hl-tag: #af3029; /* red-600 */ + --hl-builtin: #a02f6f; /* magenta-600 */ +} + +.dark .highlight { + --hl-comment: #878580; /* base-500 */ + --hl-punct: #9f9d96; /* base-400 */ + --hl-keyword: #879a39; /* green-400 */ + --hl-string: #3aa99f; /* cyan-400 */ + --hl-function: #da702c; /* orange-400 */ + --hl-attr: #4385be; /* blue-400 */ + --hl-number: #8b7ec8; /* purple-400 */ + --hl-constant: #d0a215; /* yellow-400 */ + --hl-tag: #d14d41; /* red-400 */ + --hl-builtin: #ce5d97; /* magenta-400 */ +} + +.highlight .c, .highlight .ch, .highlight .cm, .highlight .cp, .highlight .cpf, +.highlight .c1, .highlight .cs { color: var(--hl-comment); font-style: italic; } +.highlight .k, .highlight .kc, .highlight .kd, .highlight .kn, .highlight .kp, +.highlight .kr, .highlight .kt { color: var(--hl-keyword); } +.highlight .nt { color: var(--hl-tag); } +.highlight .nf, .highlight .fm { color: var(--hl-function); } +.highlight .na { color: var(--hl-attr); } +.highlight .nb, .highlight .bp, .highlight .ni { color: var(--hl-builtin); } +.highlight .nc, .highlight .nn, .highlight .no { color: var(--hl-constant); } +.highlight .s, .highlight .s1, .highlight .s2, .highlight .sa, .highlight .sb, +.highlight .sc, .highlight .sd, .highlight .se, .highlight .sh, .highlight .si, +.highlight .sr, .highlight .ss, .highlight .sx, .highlight .dl { color: var(--hl-string); } +.highlight .m, .highlight .mb, .highlight .mf, .highlight .mh, .highlight .mi, +.highlight .mo, .highlight .il { color: var(--hl-number); } +.highlight .o, .highlight .ow, .highlight .p { color: var(--hl-punct); } +.highlight .err { color: var(--hl-tag); } + @layer base { * { border-color: var(--border); diff --git a/hk.pkl b/hk.pkl index d212bd7..faef573 100644 --- a/hk.pkl +++ b/hk.pkl @@ -12,6 +12,20 @@ local linters = new Mapping { glob = List("assets/**/*.ts") check = "tsc --noEmit -p assets/tsconfig.json" } + // Keep priv/registry/*.json (what consumers actually install) in lockstep + // with the canonical sources it's generated from. When any of those sources + // is committed, regenerate and re-stage the manifests so they can never + // drift. `mix test` independently guards this via a registry drift test. + ["shadix-registry"] { + glob = List( + "lib/shadix/components/*.ex", + "lib/shadix/cn.ex", + "lib/shadix/form.ex", + "assets/ts/*.ts" + ) + fix = "mix shadix.gen.registry" + stage = List("priv/registry/*.json") + } } hooks { diff --git a/lib/shadix/components/button.ex b/lib/shadix/components/button.ex index 6317822..28ef25f 100644 --- a/lib/shadix/components/button.ex +++ b/lib/shadix/components/button.ex @@ -10,7 +10,7 @@ defmodule Shadix.Components.Button do import Shadix.Cn - @base "inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" + @base "inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none active:not-aria-[haspopup]:translate-y-px focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" @variants %{ "default" => "bg-primary text-primary-foreground hover:bg-primary/90", diff --git a/lib/shadix/components/select.ex b/lib/shadix/components/select.ex index 9de65b7..114316e 100644 --- a/lib/shadix/components/select.ex +++ b/lib/shadix/components/select.ex @@ -74,7 +74,12 @@ defmodule Shadix.Components.Select do phx-click={toggle_listbox(@id)} class={@trigger_class} > - {@placeholder} + {@placeholder} 2.0", only: [:dev, :test, :docs]}, {:makeup_html, ">= 0.0.0", only: [:dev, :test, :docs]}, + # Icons for the docs-site examples (not used by the library itself). + {:phosphor_live_view, "~> 2.1", only: [:dev, :test, :docs]}, + # Dev harness / docs-site HTTP server + Tailwind v4 CLI wrapper. {:bandit, "~> 1.5", only: [:dev, :docs]}, {:tailwind, "~> 0.3", only: [:dev, :docs], runtime: false} diff --git a/mix.lock b/mix.lock index 5af3116..35b40a3 100644 --- a/mix.lock +++ b/mix.lock @@ -25,6 +25,7 @@ "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.32", "4977ad4cfab7868f3d2ba390a2cd6c62412614d48f798e85785602281a0a3827", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3c9d8e76373414259ec5699a809991d1324808bee1d297730599e273282cee34"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, + "phosphor_live_view": {:hex, :phosphor_live_view, "2.1.0", "41a433b3a9493896ce085460c1e67ab23979b8b7ce82406a63029b8cc638c348", [:mix], [{:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "723f2d92765e1b096b2b117651c4564576d3e693d155273026c0f556de87811a"}, "plug": {:hex, :plug, "1.19.2", "e4950525b22c6789dfb38a3f95d47171ba159da3fc5a33be9643b43d5e8adb98", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b6fce20a56af5e60fa5dfecf3f907bb98ec981be43c79a3809a499bc3d133de0"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"}, diff --git a/priv/registry/button.json b/priv/registry/button.json index 5e952f3..62b32b5 100644 --- a/priv/registry/button.json +++ b/priv/registry/button.json @@ -1,7 +1,7 @@ { "files": [ { - "content": "defmodule Shadix.Components.Button do\n @moduledoc \"\"\"\n A button styled with the shadcn variant/size system.\n\n Renders a `\n \"\"\"\n end\nend\n", + "content": "defmodule Shadix.Components.Button do\n @moduledoc \"\"\"\n A button styled with the shadcn variant/size system.\n\n Renders a `\n \"\"\"\n end\nend\n", "path": "button.ex" } ], diff --git a/priv/registry/select.json b/priv/registry/select.json index 9808037..1ebbdbe 100644 --- a/priv/registry/select.json +++ b/priv/registry/select.json @@ -1,7 +1,7 @@ { "files": [ { - "content": "defmodule Shadix.Components.Select do\n @moduledoc \"\"\"\n A field-aware select (listbox) built on client-side JS commands, Floating UI,\n and the `ShadixSelect` hook.\n\n Unlike a native `
`. It is\n field-aware via `Shadix.Form`: it derives its `name`/`value` from a\n `Phoenix.HTML.FormField` and carries the selected value in a hidden ``\n so it submits with the surrounding form.\n\n The trigger toggles the content with `toggle_listbox/1`; the `ShadixSelect`\n hook (assets/ts/select.ts) watches the content's visibility via a\n `MutationObserver`, positions it under the trigger with `@floating-ui/dom`,\n handles keyboard navigation (arrows/Home/End/type-ahead/Enter/Escape), and on\n select writes the chosen value into the hidden input (dispatching an `input`\n event), updates the trigger label, marks `aria-selected`, then closes and\n refocuses the trigger. It initializes the selection from the field value.\n\n Trigger and content ids derive from the required `:id` so `aria-controls`,\n `data-trigger`, and the JS targets line up stably.\n \"\"\"\n use Phoenix.Component\n\n alias Phoenix.LiveView.JS\n\n import Shadix.Cn\n import Shadix.Form\n\n @trigger_class \"flex h-9 w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder=true]:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\"\n\n @content_class \"hidden fixed left-0 top-0 z-50 max-h-96 min-w-[8rem] origin-top overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md\"\n\n @doc \"\"\"\n Renders a field-aware select.\n\n The required `:field` supplies the hidden input's `name` and the initial\n value. The required `:id` derives the trigger/content ids. Pass `select_item/1`\n children in the default slot.\n \"\"\"\n attr(:id, :string, required: true)\n attr(:field, Phoenix.HTML.FormField, required: true)\n attr(:placeholder, :string, default: \"Select…\")\n attr(:class, :string, default: nil)\n slot(:inner_block, required: true)\n\n def select(assigns) do\n %{name: name, value: value, errors: errors} = field_attrs(assigns.field)\n value = if value in [nil, \"\"], do: nil, else: to_string(value)\n\n assigns =\n assign(assigns,\n name: name,\n value: value,\n errors: errors,\n trigger_class: @trigger_class,\n content_class: cn([@content_class, assigns.class])\n )\n\n ~H\"\"\"\n \n \n {@placeholder}\n \n \n \n \n \n {render_slot(@inner_block)}\n
\n \n {List.first(@errors)}\n

\n \"\"\"\n end\n\n @doc \"\"\"\n Returns the `Phoenix.LiveView.JS` command that toggles the listbox with `id`.\n\n Reveals/hides the content panel with enter/leave transitions; the\n `ShadixSelect` hook reacts to the visibility change to position the listbox\n and wire up keyboard/outside-click handling.\n \"\"\"\n def toggle_listbox(id) do\n JS.toggle(\n to: \"##{id}-content\",\n in: {\"transition ease-out duration-100\", \"opacity-0 scale-95\", \"opacity-100 scale-100\"},\n out: {\"transition ease-in duration-75\", \"opacity-100 scale-100\", \"opacity-0 scale-95\"},\n display: \"block\"\n )\n end\n\n @doc \"\"\"\n Returns the `Phoenix.LiveView.JS` command that closes the listbox with `id`.\n\n Used by the hook's `data-on-close` (Escape / outside click) and after a\n selection.\n \"\"\"\n def hide_listbox(id) do\n JS.hide(\n to: \"##{id}-content\",\n transition:\n {\"transition ease-in duration-75\", \"opacity-100 scale-100\", \"opacity-0 scale-95\"}\n )\n end\n\n @doc \"\"\"\n A selectable option within a select.\n\n `:value` is the value submitted with the form when chosen; the inner block is\n the visible label. The `ShadixSelect` hook selects it on click/Enter.\n \"\"\"\n attr(:value, :string, required: true)\n attr(:disabled, :boolean, default: false)\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n slot(:inner_block, required: true)\n\n def select_item(assigns) do\n ~H\"\"\"\n \n \n \n \n \n \n {render_slot(@inner_block)}\n \n \"\"\"\n end\nend\n", + "content": "defmodule Shadix.Components.Select do\n @moduledoc \"\"\"\n A field-aware select (listbox) built on client-side JS commands, Floating UI,\n and the `ShadixSelect` hook.\n\n Unlike a native ``\n so it submits with the surrounding form.\n\n The trigger toggles the content with `toggle_listbox/1`; the `ShadixSelect`\n hook (assets/ts/select.ts) watches the content's visibility via a\n `MutationObserver`, positions it under the trigger with `@floating-ui/dom`,\n handles keyboard navigation (arrows/Home/End/type-ahead/Enter/Escape), and on\n select writes the chosen value into the hidden input (dispatching an `input`\n event), updates the trigger label, marks `aria-selected`, then closes and\n refocuses the trigger. It initializes the selection from the field value.\n\n Trigger and content ids derive from the required `:id` so `aria-controls`,\n `data-trigger`, and the JS targets line up stably.\n \"\"\"\n use Phoenix.Component\n\n alias Phoenix.LiveView.JS\n\n import Shadix.Cn\n import Shadix.Form\n\n @trigger_class \"flex h-9 w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder=true]:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\"\n\n @content_class \"hidden fixed left-0 top-0 z-50 max-h-96 min-w-[8rem] origin-top overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md\"\n\n @doc \"\"\"\n Renders a field-aware select.\n\n The required `:field` supplies the hidden input's `name` and the initial\n value. The required `:id` derives the trigger/content ids. Pass `select_item/1`\n children in the default slot.\n \"\"\"\n attr(:id, :string, required: true)\n attr(:field, Phoenix.HTML.FormField, required: true)\n attr(:placeholder, :string, default: \"Select…\")\n attr(:class, :string, default: nil)\n slot(:inner_block, required: true)\n\n def select(assigns) do\n %{name: name, value: value, errors: errors} = field_attrs(assigns.field)\n value = if value in [nil, \"\"], do: nil, else: to_string(value)\n\n assigns =\n assign(assigns,\n name: name,\n value: value,\n errors: errors,\n trigger_class: @trigger_class,\n content_class: cn([@content_class, assigns.class])\n )\n\n ~H\"\"\"\n \n \n {@placeholder}\n \n \n \n \n \n {render_slot(@inner_block)}\n \n \n {List.first(@errors)}\n

\n \"\"\"\n end\n\n @doc \"\"\"\n Returns the `Phoenix.LiveView.JS` command that toggles the listbox with `id`.\n\n Reveals/hides the content panel with enter/leave transitions; the\n `ShadixSelect` hook reacts to the visibility change to position the listbox\n and wire up keyboard/outside-click handling.\n \"\"\"\n def toggle_listbox(id) do\n JS.toggle(\n to: \"##{id}-content\",\n in: {\"transition ease-out duration-100\", \"opacity-0 scale-95\", \"opacity-100 scale-100\"},\n out: {\"transition ease-in duration-75\", \"opacity-100 scale-100\", \"opacity-0 scale-95\"},\n display: \"block\"\n )\n end\n\n @doc \"\"\"\n Returns the `Phoenix.LiveView.JS` command that closes the listbox with `id`.\n\n Used by the hook's `data-on-close` (Escape / outside click) and after a\n selection.\n \"\"\"\n def hide_listbox(id) do\n JS.hide(\n to: \"##{id}-content\",\n transition:\n {\"transition ease-in duration-75\", \"opacity-100 scale-100\", \"opacity-0 scale-95\"}\n )\n end\n\n @doc \"\"\"\n A selectable option within a select.\n\n `:value` is the value submitted with the form when chosen; the inner block is\n the visible label. The `ShadixSelect` hook selects it on click/Enter.\n \"\"\"\n attr(:value, :string, required: true)\n attr(:disabled, :boolean, default: false)\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n slot(:inner_block, required: true)\n\n def select_item(assigns) do\n ~H\"\"\"\n \n \n \n \n \n \n {render_slot(@inner_block)}\n \n \"\"\"\n end\nend\n", "path": "select.ex" } ], diff --git a/priv/static/assets/css/app.css b/priv/static/assets/css/app.css index 7b714fb..b1d061a 100644 --- a/priv/static/assets/css/app.css +++ b/priv/static/assets/css/app.css @@ -294,6 +294,9 @@ .bottom-0 { bottom: calc(var(--spacing) * 0); } + .bottom-8 { + bottom: calc(var(--spacing) * 8); + } .-left-12 { left: calc(var(--spacing) * -12); } @@ -575,6 +578,9 @@ .h-svh { height: 100svh; } + .max-h-28 { + max-height: calc(var(--spacing) * 28); + } .max-h-72 { max-height: calc(var(--spacing) * 72); } @@ -1129,6 +1135,10 @@ --tw-gradient-position: to bottom right in oklab; background-image: linear-gradient(var(--tw-gradient-stops)); } + .bg-gradient-to-t { + --tw-gradient-position: to top in oklab; + background-image: linear-gradient(var(--tw-gradient-stops)); + } .from-accent\/50 { --tw-gradient-from: var(--accent); @supports (color: color-mix(in lab, red, red)) { @@ -1136,6 +1146,10 @@ } --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); } + .from-background { + --tw-gradient-from: var(--background); + --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); + } .from-primary\/30 { --tw-gradient-from: var(--primary); @supports (color: color-mix(in lab, red, red)) { @@ -1171,6 +1185,10 @@ } --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); } + .to-transparent { + --tw-gradient-to: transparent; + --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); + } .object-cover { object-fit: cover; } @@ -1621,6 +1639,26 @@ visibility: visible; } } + .peer-checked\:flex { + &:is(:where(.peer):checked ~ *) { + display: flex; + } + } + .peer-checked\:hidden { + &:is(:where(.peer):checked ~ *) { + display: none; + } + } + .peer-checked\:max-h-none { + &:is(:where(.peer):checked ~ *) { + max-height: none; + } + } + .peer-checked\:overflow-x-auto { + &:is(:where(.peer):checked ~ *) { + overflow-x: auto; + } + } .peer-disabled\:cursor-not-allowed { &:is(:where(.peer):disabled ~ *) { cursor: not-allowed; @@ -2084,6 +2122,14 @@ outline-style: none; } } + .active\:not-aria-\[haspopup\]\:translate-y-px { + &:active { + &:not(*[aria-haspopup]) { + --tw-translate-y: 1px; + translate: var(--tw-translate-x) var(--tw-translate-y); + } + } + } .disabled\:pointer-events-none { &:disabled { pointer-events: none; @@ -3169,6 +3215,64 @@ --input: oklch(1 0 0 / 15%); --ring: oklch(0.556 0 0); } +.highlight { + --hl-comment: #6f6e69; + --hl-punct: #6f6e69; + --hl-keyword: #66800b; + --hl-string: #24837b; + --hl-function: #bc5215; + --hl-attr: #205ea6; + --hl-number: #5e409d; + --hl-constant: #ad8301; + --hl-tag: #af3029; + --hl-builtin: #a02f6f; +} +.dark .highlight { + --hl-comment: #878580; + --hl-punct: #9f9d96; + --hl-keyword: #879a39; + --hl-string: #3aa99f; + --hl-function: #da702c; + --hl-attr: #4385be; + --hl-number: #8b7ec8; + --hl-constant: #d0a215; + --hl-tag: #d14d41; + --hl-builtin: #ce5d97; +} +.highlight .c, .highlight .ch, .highlight .cm, .highlight .cp, .highlight .cpf, .highlight .c1, .highlight .cs { + color: var(--hl-comment); + font-style: italic; +} +.highlight .k, .highlight .kc, .highlight .kd, .highlight .kn, .highlight .kp, .highlight .kr, .highlight .kt { + color: var(--hl-keyword); +} +.highlight .nt { + color: var(--hl-tag); +} +.highlight .nf, .highlight .fm { + color: var(--hl-function); +} +.highlight .na { + color: var(--hl-attr); +} +.highlight .nb, .highlight .bp, .highlight .ni { + color: var(--hl-builtin); +} +.highlight .nc, .highlight .nn, .highlight .no { + color: var(--hl-constant); +} +.highlight .s, .highlight .s1, .highlight .s2, .highlight .sa, .highlight .sb, .highlight .sc, .highlight .sd, .highlight .se, .highlight .sh, .highlight .si, .highlight .sr, .highlight .ss, .highlight .sx, .highlight .dl { + color: var(--hl-string); +} +.highlight .m, .highlight .mb, .highlight .mf, .highlight .mh, .highlight .mi, .highlight .mo, .highlight .il { + color: var(--hl-number); +} +.highlight .o, .highlight .ow, .highlight .p { + color: var(--hl-punct); +} +.highlight .err { + color: var(--hl-tag); +} @layer base { * { border-color: var(--border); diff --git a/test/mix/tasks/shadix_gen_registry_test.exs b/test/mix/tasks/shadix_gen_registry_test.exs index 6ca0ba1..ae20553 100644 --- a/test/mix/tasks/shadix_gen_registry_test.exs +++ b/test/mix/tasks/shadix_gen_registry_test.exs @@ -79,4 +79,29 @@ defmodule Mix.Tasks.Shadix.Gen.RegistryTest do # Pretty JSON should have newlines assert raw =~ "\n" end + + # Drift guard: the committed priv/registry/*.json must match what the generator + # produces from the current sources. This is what makes `mix shadix.gen.registry` + # non-optional — editing a component source without regenerating fails CI here. + test "committed priv/registry is in sync with the sources", %{tmp_dir: tmp_dir} do + Mix.Tasks.Shadix.Gen.Registry.run(["--out", tmp_dir]) + + committed = Path.wildcard("priv/registry/*.json") |> Enum.map(&Path.basename/1) |> Enum.sort() + + generated = + Path.wildcard(Path.join(tmp_dir, "*.json")) |> Enum.map(&Path.basename/1) |> Enum.sort() + + assert generated == committed, + "priv/registry is missing or has extra manifests. " <> + "Run `mix shadix.gen.registry` and commit the result." + + stale = + for name <- generated, + File.read!(Path.join(tmp_dir, name)) != File.read!(Path.join("priv/registry", name)), + do: name + + assert stale == [], + "These committed registry manifests are out of date: #{Enum.join(stale, ", ")}.\n" <> + "Run `mix shadix.gen.registry` and commit the result." + end end diff --git a/test/website/components/component_live_test.exs b/test/website/components/component_live_test.exs index 9b514d5..ae94f2a 100644 --- a/test/website/components/component_live_test.exs +++ b/test/website/components/component_live_test.exs @@ -35,6 +35,26 @@ defmodule Shadix.Website.Components.ComponentLiveTest do assert html =~ ~s(data-size="sm") end + test "the size select keeps its value across a configure round-trip", %{conn: conn} do + {:ok, view, _html} = live(conn, "/components/button") + + # The trigger label is filled in client-side by the ShadixSelect hook. It + # must be marked phx-update=ignore so a server re-render (e.g. another + # control firing `configure`) can't clobber the user's choice back to the + # "Select size…" placeholder. Regression guard for the select-loses-its-value + # bug: without this attribute the label resets on every round-trip. + assert has_element?(view, "#cfg-size-value[phx-update=ignore]") + + render_change(view, "configure", %{"variant" => "default", "size" => "lg"}) + + # The chosen value survives the round-trip: the hidden input that submits + # with the form holds "lg", the trigger has left its placeholder state, and + # the label is still protected from server re-renders. + assert has_element?(view, "#cfg-size-input[value=lg]") + assert has_element?(view, "#cfg-size-trigger[data-placeholder=false]") + assert has_element?(view, "#cfg-size-value[phx-update=ignore]") + end + test "renders the input page", %{conn: conn} do {:ok, _view, html} = live(conn, "/components/input") assert html =~ "A styled text input" diff --git a/test/website/components/highlight_test.exs b/test/website/components/highlight_test.exs index 51fc09b..b95dbc8 100644 --- a/test/website/components/highlight_test.exs +++ b/test/website/components/highlight_test.exs @@ -8,7 +8,11 @@ defmodule Shadix.Website.Components.HighlightTest do assert html =~ "button" end - test "stylesheet/0 returns CSS" do - assert Highlight.stylesheet() =~ ".highlight" or Highlight.stylesheet() =~ "makeup" + test "heex/1 emits token classes the Flexoki stylesheet targets" do + html = Highlight.heex(~s(<.button variant="outline">Hi)) + # The component name is a function token and the attribute value a string + # token — `assets/css/app.css` colors `.nf`/`.s` etc. + assert html =~ ~s(class="nf") + assert html =~ ~s(class="s") end end diff --git a/website/components/component_live.ex b/website/components/component_live.ex index 5ecc6d3..b0ce499 100644 --- a/website/components/component_live.ex +++ b/website/components/component_live.ex @@ -137,18 +137,21 @@ defmodule Shadix.Website.Components.ComponentLive do <.input :if={c.type == :text} name={to_string(c.name)} value={@props[c.name]} /> -
{Phoenix.HTML.raw(Highlight.heex(@snippet))}
+ <.code_block id="code-snippet" source={@snippet} />

Examples

-
+

{demo.title}

{demo.description}

{demo.render.(%{})}
-
{Phoenix.HTML.raw(Highlight.heex(demo.source))}
+ <.code_block id={"code-demo-#{i}"} source={demo.source} />
@@ -178,4 +181,48 @@ defmodule Shadix.Website.Components.ComponentLive do """ end + + # Tall code samples (more source lines than this) are collapsed behind a + # CSS-only "Show code" disclosure that previews ~100px first. It's pure CSS + # (a visually-hidden checkbox toggled by a label) so it needs no JS hook and + # keeps working even if the socket is dead. + @code_collapse_min_lines 6 + + attr(:id, :string, required: true) + attr(:source, :string, required: true) + + defp code_block(assigns) do + assigns = + assign(assigns, + html: Highlight.heex(assigns.source), + collapsible: length(String.split(assigns.source, "\n")) > @code_collapse_min_lines + ) + + ~H""" +
{Phoenix.HTML.raw(@html)}
+
+ +
{Phoenix.HTML.raw(@html)}
+ <%!-- Collapsed: a fade sitting directly on top of the "Show code" bar (no + gap), then the bar itself overlaying the clipped bottom. --%> +
+ + <%!-- Expanded: a static bar appended below the full-height code. --%> + +
+ """ + end end diff --git a/website/components/docs/button.ex b/website/components/docs/button.ex index bf24c89..7085b22 100644 --- a/website/components/docs/button.ex +++ b/website/components/docs/button.ex @@ -16,7 +16,12 @@ defmodule Shadix.Website.Components.Docs.Button do @impl true def preview(assigns) do - ~H"<.button {@props}>{@slot}" + ~H""" + <.button {@props}> + + {if @props[:size] != "icon", do: @slot} + + """ end demo("Variants", [description: "The six built-in variants."], """ @@ -30,11 +35,16 @@ defmodule Shadix.Website.Components.Docs.Button do """) - demo("Sizes", [description: "Three sizes."], """ -
- <.button size="sm">Small - <.button size="default">Default - <.button size="lg">Large -
- """) + demo( + "Sizes", + [description: "Four sizes. The icon size takes an icon child instead of text."], + """ +
+ <.button size="sm">Small + <.button size="default">Default + <.button size="lg">Large + <.button size="icon"> +
+ """ + ) end diff --git a/website/components/highlight.ex b/website/components/highlight.ex index 3673d98..f7d8931 100644 --- a/website/components/highlight.ex +++ b/website/components/highlight.ex @@ -3,6 +3,9 @@ defmodule Shadix.Website.Components.Highlight do Server-side HEEx syntax highlighting via `makeup_eex`'s HEEx lexer. The lexer is looked up from Makeup's registry by name ("heex"), so we don't hardcode its module. On any lexing error we fall back to HTML-escaped plain text. + + Only the token markup is produced here; the colors live in `assets/css/app.css` + (a Flexoki palette with light/dark variants) so they can theme with the page. """ @spec heex(String.t()) :: String.t() @@ -12,7 +15,4 @@ defmodule Shadix.Website.Components.Highlight do rescue _ -> code |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string() end - - @spec stylesheet() :: String.t() - def stylesheet, do: Makeup.stylesheet() end diff --git a/website/layout.ex b/website/layout.ex index 6b41e6b..a25b288 100644 --- a/website/layout.ex +++ b/website/layout.ex @@ -20,11 +20,6 @@ defmodule Shadix.Website.Layout do Shadix — components for Phoenix LiveView - {Phoenix.HTML.raw("")}