SaladUI

repository·main·Indexed 21 days ago

https://github.com/bluzky/salad_ui

A Phoenix LiveView components library and framework inspired by shadcn/ui. It provides accessible, customizable UI elements designed for Tailwind CSS, featuring a JavaScript-driven core with a state machine, component registry, and LiveView hooks. Supports both a quick library setup and a local installation for full customization of components like Accordion, Dialog, and Select.

Tokens
63.6K
Snippets
142
Records
225
Agent score
75%

What's inside salad_ui

  1. Explore the Salad UI JavaScript File Structure

    main

    The JavaScript assets for Salad UI are located in assets/salad_ui/. The structure is divided into core logic and individual UI components:

    Core Module (assets/salad_ui/core/)

    Contains the foundational logic used by all components:

    • component.js: The base Component class.
    • state-machine.js: Logic for managing component states.
    • hook.js: The Phoenix LiveView hook implementation.
    • factory.js: The registry and factory for component instantiation.
    • utils.js: DOM, animation, and class utilities.
    • collection.js: Manages selectable/focusable item collections.
    • focus-trap.js: Utility for trapping focus within an element.
    • click-outside.js: Monitors clicks outside a specific element.
    • portal.js: Utility for DOM re-parenting.
    • positioner.js & positioned-element.js: Math and logic for floating elements (popovers, tooltips, etc.).
    • scroll-manager.js: Handles repositioning during scroll/resize events.

    Components (assets/salad_ui/components/)

    Contains individual component implementations such as accordion.js, dialog.js, dropdown_menu.js, popover.js, select.js, tabs.js, and more.

  2. Core SaladUI JavaScript modules and responsibilities

    main

    The salad_ui/core directory contains the framework-level JavaScript that powers all interactive components. These modules are designed to be component-agnostic.

    Primary Framework Modules

    • Component (component.js): The base class for all interactive components. It handles option/event-mapping, state machine wiring, part querying, event binding, ARIA attributes, and lifecycle hooks (setupComponentEvents, teardownComponentEvents, afterMount, beforeDestroy).
    • StateMachine (state-machine.js): A pure state/transition engine. It manages state transitions (exit → update → onStateChanged → enter) and supports asynchronous onStateChanged callbacks for animations. It has no DOM dependencies.
    • registry (factory.js): A ComponentRegistry instance that maps data-component strings to component classes. It is the only module authorized to call setupEvents() and afterMount() on managed components.
    • SaladUIHook (hook.js): The Phoenix LiveView hook (phx-hook="SaladUI"). It manages the component lifecycle (mounted, updated, destroyed) and relays saladui:command server events to component.handleCommand().

    Utility Modules

    • Collection (collection.js): Manages selectable/focusable items (e.g., for select, menu, accordion).
    • FocusTrap (focus-trap.js): Confines focus within an element and restores previous focus on deactivate().
    • ClickOutsideMonitor (click-outside.js): Detects clicks outside a set of elements. Requires paired start()/stop() calls and must be destroy()-ed in teardownComponentEvents().
    • Portal (portal.js): Moves elements to a different DOM parent (e.g., document.body) to manage stacking contexts.
    • Positioner (positioner.js): Pure math for computing fixed-position coordinates for floating elements.
    • PositionedElement (positioned-element.js): A high-level composition of Positioner, Portal, ScrollManager, and FocusTrap for floating UI like popovers or tooltips.
    • ScrollManager (scroll-manager.js): Watches scroll/resize on ancestors and target size to trigger repositioning.
    • utils.js: Contains animateTransition, executeAnimation, addOrRemoveClasses, and queryDOM (used by Component.queryParts() to find data-part elements).
  3. How the SaladUI JavaScript Architecture works

    main

    SaladUI's JavaScript system is designed to integrate seamlessly with Phoenix LiveView. The architecture is built around several core entities:

    • Component: The base class for all UI elements.
    • StateMachine: Manages the logic of state transitions and animations.
    • Registry: Handles the registration and lookup of components.
    • Hook: Manages the integration and lifecycle within the Phoenix LiveView context.

    Data flows through three primary patterns:

    • Server-to-Client: Using commands to control components from LiveView.
    • Client-to-Server: Using events to send data back to the server.
    • Client-to-Client: Direct commands between different components.
  4. Implement ARIA accessibility patterns for components

    main

    SaladUI components use a getAriaConfig() method to define accessibility attributes for different parts of the component. This method returns a configuration object that maps component parts to ARIA roles and states. Use this pattern to ensure components like Dialogs, Menus, Selects, and Tabs are accessible. You can use dynamic values by providing functions that return strings, allowing attributes to react to component state or options.

    // Example: Dynamic ARIA for a slider
    getAriaConfig() {
      return {
        slider: {
          all: {
            role: "slider",
            orientation: () => this.options.orientation || "horizontal",
            valuemin: () => this.min.toString(),
            valuemax: () => this.max.toString(),
            valuenow: () => this.value.toString(),
            valuetext: () => this.formatValue(this.value)
          }
        }
      };
    }
  5. How SaladUI components are wired up

    main

    SaladUI components follow a specific initialization flow triggered by Phoenix LiveView. When a DOM element with data-component="[type]" and phx-hook="SaladUI" is mounted, the following sequence occurs:

    1. SaladUIHook.mounted(): The LiveView hook intercepts the mount event.
    2. registry.create("type", el, hookContext): The hook uses the component registry to find the correct class and instantiate it.
    3. new ComponentClass(el, hookContext): The specific component class (which extends Component) is instantiated.
    4. instance.setupEvents(): The factory calls this method exactly once to bind events.
    5. instance.afterMount(): The factory calls this method exactly once after setup is complete.

    Once this sequence finishes, the component is live and driven by transition(event, params) calls.

    data-component="dialog" + phx-hook="SaladUI"
            │
            ▼
    SaladUIHook.mounted() → registry.create("dialog", el, hookContext)
            │
            ▼
    new DialogComponent(el, hookContext)   // extends Component
            │
            ▼
    instance.setupEvents()   ← called once, by factory.js
            │
            ▼
    instance.afterMount()    ← called once, by factory.js
            │
            ▼
    component is live: transition(event, params) drives everything from here
  6. How Client → Server communication works

    main

    SaladUI enables JavaScript components to send events to the Phoenix LiveView server. This pattern is used for user actions requiring server processing, such as form submissions, data persistence, or navigation.

    1. Event Mapping Configuration

    In your Elixir component, you must map on-* attributes to client event names. This is done by collecting assigned handlers into an event_map and passing it to the template via data-event-mappings.

    defmodule SaladUI.Dialog do
      def dialog(assigns) do
        event_map = event_mappings(assigns)
        assigns = assign(assigns, :event_map, json(event_map))
    
        ~H"""
        <div
          data-component="dialog"
          data-event-mappings={@event_map}
          phx-hook="SaladUI"
        >
          <!-- component content -->
        </div>
        """
      end
    end

    2. Sending Events from JavaScript

    Inside your JavaScript component class, use this.pushEvent(name, data) to dispatch events.

    class DialogComponent extends Component {
      onOpenEnter() {
        // Simple event
        this.pushEvent("open");
    
        // Event with data
        this.pushEvent("open", {
          dialogId: this.el.id,
          timestamp: Date.now()
        });
      }
    }

    3. Handling Events in LiveView

    In your LiveView module, use standard handle_event/3 callbacks. The event name in Elixir should match the mapped client event name.

    defmodule MyAppWeb.PageLive do
      use MyAppWeb, :live_view
    
      def handle_event("dialog_opened", params, socket) do
        %{"dialogId" => dialog_id, "timestamp" => timestamp} = params
        {:noreply, socket}
      end
    end

    4. Using Phoenix.LiveView.JS

    For simple interactions, you can use JS.push/2 directly in your HEEX templates.

    <.dialog on-open={JS.push("dialog_opened")} on-close={JS.push("dialog_closed")}>
      <.button phx-click={JS.push("action_clicked", value: %{action: "save"})}>
        Save
      </.button>
    </.dialog>
    this.pushEvent("open", { dialogId: this.el.id });
  7. Rules for building SaladUI components

    main

    To avoid bugs and memory leaks, follow these four mandatory rules:

    1. Never call setupEvents() manually: This method is called exactly once by the factory. Subclasses registered via SaladUI.register(...) must not invoke it.
    2. Pair setup and teardown: Every listener or utility created in setupComponentEvents() must be undone in teardownComponentEvents(). Do not scatter cleanup across state handlers or beforeDestroy().
    3. Return fresh config objects: getComponentConfig() must return a new object literal on every call. Because bindStateHandlers() mutates the returned object to bind instance methods, returning a cached object will cause handlers to leak across different component instances.
    4. Avoid constructor side-effects: Do not attempt to set up one-time listeners or trigger transition() calls inside the constructor. The constructor runs before setupEvents(), meaning listeners are not yet attached and the component is not yet ready to handle transitions.
  8. How Client → Client communication works

    main

    Components can communicate directly with each other in the browser without a server round-trip. This is ideal for immediate UI feedback, local filtering, or synchronizing component states.

    Using SaladUI.JS.dispatch_command/3

    Use SaladUI.JS.dispatch_command/3 within Phoenix JS commands in your templates. This generates a salad_ui:command custom DOM event.

    Syntax:

    • command: The name of the command to trigger.
    • to: The CSS selector (e.g., #id) of the target component.
    • detail: (Optional) A map of parameters to pass to the target.
    <!-- Simple command -->
    <.button phx-click={SaladUI.JS.dispatch_command("open", to: "#user-dialog")}>
      Open Dialog
    </.button>
    
    <!-- Command with data -->
    <.button phx-click={SaladUI.JS.dispatch_command("highlight", 
      to: "#data-table", 
      detail: %{row_id: @selected_row})}>
      Highlight Row
    </.button>
    
    <!-- Chaining multiple commands -->
    <.button phx-click={
      %JS{}
      |> SaladUI.JS.dispatch_command("close", to: "#main-dialog")
      |> SaladUI.JS.dispatch_command("open", to: "#confirmation-dialog")
    }>
      Show Confirmation
    </.button>

    Handling Commands in Components

    Target components receive these commands via their handleCommand(command, params) method, identical to how they handle server-sent commands.

    class DataTableComponent extends Component {
      handleCommand(command, params) {
        switch (command) {
          case "highlight":
            this.highlightRow(params.row_id);
            return true;
          case "filter":
            this.applyFilters(params.filters);
            return true;
          default:
            return super.handleCommand(command, params);
        }
      }
    }
    
    <.button phx-click={SaladUI.JS.dispatch_command("open", to: "#user-dialog")}>
  9. Understand the SaladUI Hybrid Architecture

    main

    SaladUI uses a hybrid client-server architecture designed to bridge Phoenix LiveView (Elixir) with a robust JavaScript component system.

    Core Architecture Components:

    • Phoenix Function Components (Elixir): Handles server-side rendering.
    • JavaScript State Machines: Manages client-side behavior, states, and transitions.
    • LiveView Hooks (SaladUIHook): Acts as the bridge for bidirectional communication between the server and the client.
    • Component Registry System: Enables dynamic instantiation of JavaScript components based on DOM attributes.

    Integration Pattern: To connect a LiveView element to the JavaScript system, use the following attributes:

    • phx-hook="SaladUI"
    • data-component="[component-name]"
  10. How StateMachine manages component states

    main

    The StateMachine class (found in core/state-machine.js) handles the logic for state transitions within a component.

    Properties

    • state: The current active state.
    • previousState: The state the component was in before the last transition.
    • stateConfig: The configuration object defining states and transitions.

    Methods

    • transition(event, params): Executes a transition based on an event and optional parameters.
    • determineNextState(transition, params): Resolves what the next state should be based on the current transition and parameters.
    • executeTransition(prev, next, params): Executes a full transition from a previous state to a next state.
    • executeStateHandler(state, type, params): Executes specific enter or exit handlers for a given state.
  11. Understand the SaladUI Component Lifecycle

    main

    SaladUI components follow a strict lifecycle managed by the SaladUIHook and the Component base class. It is important to note that every LiveView patch to a component's root element destroys and fully recreates the component instance; the library does not diff or preserve old instances.

    Initialization Flow

    1. Mount: LiveView mounts the element with phx-hook="SaladUI".
    2. Registration: registry.create() instantiates the ComponentClass.
      • constructor(): Parses data-options, initializes event mappings, config, state machine, and queries data-part elements.
      • setupEvents(): Adds click handlers, command listeners, and mouse/keyboard maps. It also calls the setupComponentEvents() hook.
      • afterMount(): Called once after setup. Override this for logic requiring both internal fields and active listeners.

    Update Flow (LiveView Patch)

    1. LiveView patches the DOM.
    2. SaladUIHook.updated() is called.
    3. The existing component is destroyed.
    4. A new component instance is initialized from scratch.

    Destruction Flow

    1. Element is removed or about to be replaced.
    2. SaladUIHook.destroyed() is called.
    3. component.destroy() executes:
      • beforeDestroy() hook: Cleanup that requires the element or parts to still be valid (e.g., FocusTrap).
      • removeAllEvents(): Removes mouse, keyboard, and command listeners. Calls the teardownComponentEvents() hook.
      • References are cleared for garbage collection.

    Rule: Subclasses must not call setupEvents() themselves; this is handled by the factory.

    // Example of overriding afterMount for initial state transitions
    class MyComponent extends Component {
      afterMount() {
        super.afterMount();
        this.transition('init_event', {});
      }
    }
  12. State Management Patterns

    main

    SaladUI uses a state machine for component logic. You can implement several patterns within getStateMachineConfig():

    • Binary State: Simple toggle between two states (e.g., open and closed).
    • Multi-State with Loading: Transitions through idle -> loading -> success or error states. Use this.pushEvent(name, params) in state handlers to notify the consumer.
    • Conditional Transitions: Instead of a string, provide a function for a transition. The function receives params and must return the name of the next state (or a different state based on validation).
    • State with History: Maintain a this.stateHistory array in the constructor and override onStateChanged(prevState, nextState) to track transitions. This allows implementing an undo command by transitioning back to a previous state.
    // Conditional Transition Example
    getStateMachineConfig() {
      return {
        editing: {
          transitions: {
            save: (params) => {
              if (this.validate(params.data)) {
                return "saved";
              } else {
                return "error";
              }
            }
          }
        }
      };
    }