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

Accessibility hook #87

Merged

Conversation

selenil
Copy link
Contributor

@selenil selenil commented Nov 18, 2024

This PR introduces a hook to integrate the Zag library with SaladUI components, inspired by @cschmatzler's work in Turboprop.

Zag is a framework-agnostic library that provides state machines for building accessible, interactive components. When a component mounts, the hook invokes the appropriate state machine to add accessibility and interactive features.

Zag is not distributed as a single npm package, but rather as a set of individual packages for each component. The salad.add task will install the correct package for the component and also import it in an index.js file in user's project. This way, the hook could access to the correct packages for each component dynamically. The salad.init task is also modified to do the neccesary setup in order to use the hook.

To make a component works with the hook:

  • Add phx-hook="ZagHook" attribute and an id to the component's root element. For components that don't need a manual id we can use the unique_id helper to generate a random id.

  • Add data-component="component-name" to the root element. This should match the Zag component you want to target. The name typically matches the name of the SaladUI component, but it can be different if needed.

  • Add a data-parts attribute to the component's root element to indicate its structure. For example, in the collapsible component, the trigger and content are both parts of the component, so we set: data-parts={Jason.encode!["trigger", "content"]}. For components containing items with their own parts (like accordions), we don't need to include the item parts here.

  • For components that contain other components, like accordion_item, we follow the same pattern. So, in the accordion item component: data-parts={Jason.encode!(["trigger", "content"])}. Additionally, these types of components require a data-value attribute. For instace, we could use something like: data-value="accordion-item-1.

  • Add a data-part attribute to each part of the component. The value should be the name of the part. For example, in the collapsible component, we have: data-parts={Jason.encode!(["trigger", "content"])}, so we need to add data-part="trigger" and data-part="content" to the collapsible_trigger and collapsible_content components respectively. Is not necessary to add the data-part="root" attribute to the root component. For components that are items, like accordion_item, we need to add data-part="item" to identify it as a part with its own parts.

  • (optional) Add a data-options attribute to the component's root element. This is a map with all options that Zag accepts for the component. The keys are the name of the options (in any case, the hook will convert them to camelCase) and the values are the posibles values for that option, as an array. If the option is a boolean, pass :bool instead. Each option also needs to have a corresponding data-option attribute with the value of the option. For example, we could set: data-options={Jason.encode!(%{open: :bool, direction: ["ltr", "rtl"]})} data-open={@open} data-direction={@direction}. All of this should match the Zag api for the component.

  • (optional) Add a data-listeners attribute to the component's root element. This is a list of events that the component could emit from the client to the server. The hook will add the corresponding listeners to the component. This follows the Zag api for events where each event is called: onEventChange. As the naming is consistent, we only need to provide the event name in the data-listeners attribute, in lower case, and the hook will find the correct event. For example, in the collapsible component, we could set: data-listeners={Jason.encode!(["open"])} to listen to the onOpenChange event. It is probably better let the user set the value of data-listeners, as any event included in listeners will need to be handled in the live view or an exception will be raised. So, instead we should use: data-listeners={Jason.encode!(@listeners)}.

  • (optionally) The hook will manage the interaction logic, so JS commands are not required in the component, but they remain functionals. We could keep them as public functions to allows users to programatically interact with the component.

Summary by Sourcery

Integrate the Zag library with SaladUI components by introducing a new hook that utilizes state machines for accessibility and interactivity. Update the salad.add and salad.init tasks to support this integration by installing necessary packages and setting up configurations.

New Features:

  • Introduce a hook to integrate the Zag library with SaladUI components, enabling accessibility and interactive features through state machines.

Enhancements:

  • Modify the salad.add task to install the correct Zag package for each component and import it into an index.js file.
  • Enhance the salad.init task to set up the necessary files and configurations for using the Zag integration hook.

Copy link

sourcery-ai bot commented Nov 18, 2024

Reviewer's Guide by Sourcery

This PR implements a hook to integrate the Zag accessibility library with SaladUI components. The implementation includes modifications to Mix tasks for component installation and initialization, along with the addition of a new ZagHook JavaScript module that handles the integration between Zag's state machines and the components.

Class diagram for the new ZagHook integration

classDiagram
    class Component {
        - el
        - context
        - service
        - api
        - cleanupFunctions
        + init()
        + initializeComponent()
        + destroy()
        + cleanup()
        + parts(el)
        + initService(component, context)
        + initApi(component)
        + render()
        + renderPart(root, name, api, opts)
        + renderItem(item)
        + spreadProps(el, attrs)
    }

    class ZagHook {
        + mounted()
        + destroyed()
        + context()
    }

    Component --> ZagHook
    note for Component "Handles the integration of Zag state machines with components"
Loading

File-Level Changes

Change Details Files
Added Zag integration setup to component installation process
  • Added function to detect target Zag component from source code
  • Automatically installs required Zag npm package
  • Updates zag/index.js with component imports
  • Added warning for components without data-component attribute
lib/mix/tasks/salad.add.ex
Modified initialization task to support Zag integration
  • Added function to copy Zag files to assets folder
  • Creates necessary directory structure for Zag integration
  • Added informative messages about ZagHook installation
lib/mix/tasks/salad.init.ex
Added utility function for generating unique IDs
  • Implemented unique_id helper using crypto and Base64 encoding
lib/salad_ui/helpers.ex
Implemented core ZagHook functionality
  • Created Component class to manage Zag state machines
  • Implemented lifecycle methods for component mounting and destruction
  • Added support for component parts and items rendering
  • Implemented props spreading and event handling
  • Added context management for options and listeners
priv/static/assets/zag/ZagHook.js
Added utility functions for Zag integration
  • Implemented property normalization for Zag compatibility
  • Added helper functions for handling options and attributes
  • Created utilities for style string conversion
  • Added camelCase conversion utility
priv/static/assets/zag/utils.js

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time. You can also use
    this command to specify where the summary should be inserted.

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey @selenil - I've reviewed your changes - here's some feedback:

Overall Comments:

  • Consider making the automatic npm package installation optional or add version constraints to prevent potential dependency conflicts.
  • Error messages in ZagHook.js could be more specific to help with debugging, especially in the component initialization and context parsing sections.
Here's what I looked at during the review
  • 🟡 General issues: 1 issue found
  • 🟢 Security: all looks good
  • 🟢 Testing: all looks good
  • 🟡 Complexity: 1 issue found
  • 🟢 Documentation: all looks good

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

}
}

spreadProps(el, attrs) {
Copy link

Choose a reason for hiding this comment

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

issue (complexity): Consider splitting the spreadProps method into smaller, focused methods for handling events and attributes separately

The spreadProps method handles too many concerns, making it hard to maintain and understand. Consider splitting it into focused methods:

spreadProps(el, attrs) {
  const eventHandlers = this.setupEventHandlers(el, attrs);
  this.updateAttributes(el, attrs);
  return () => this.cleanupEventHandlers(eventHandlers);
}

setupEventHandlers(el, attrs) {
  const handlers = [];
  Object.entries(attrs)
    .filter(([key]) => key.startsWith('on'))
    .forEach(([key, handler]) => {
      const event = key.substring(2).toLowerCase();
      el.addEventListener(event, handler);
      handlers.push([event, handler]);
    });
  return handlers;
}

updateAttributes(el, attrs) {
  Object.entries(attrs)
    .filter(([key]) => !key.startsWith('on'))
    .forEach(([key, value]) => {
      if (key === 'id') return; // Preserve LiveView ID
      if (value == null) {
        el.removeAttribute(key.toLowerCase());
        return;
      }
      if (['value', 'checked', 'htmlFor'].includes(key)) {
        el[key] = value;
      } else {
        el.setAttribute(key.toLowerCase(), typeof value === 'boolean' ? value || undefined : value);
      }
    });
}

cleanupEventHandlers(handlers) {
  return () => handlers.forEach(([event, handler]) => 
    this.el.removeEventListener(event, handler)
  );
}

This refactoring:

  1. Separates event handling from attribute management
  2. Removes the unnecessary WeakMap
  3. Simplifies the attribute update logic
  4. Makes the code more testable and maintainable

The functionality remains identical while reducing cognitive load.

@bluzky
Copy link
Owner

bluzky commented Nov 19, 2024

@selenil could you please give me an example, it's hard to imagine how it work

@selenil
Copy link
Contributor Author

selenil commented Nov 20, 2024

@selenil could you please give me an example, it's hard to imagine how it work

Sure. Check out the collapsible component. This is how we make a component work with the hook. Now, the hook can recognize it and automatically add all the necessary accessibility attributes. Additional options can be configured using the data-options attribute.

I will also evaluate the hook's performance to ensure it doesn't become a heavy load when multiple components are on the screen.

@bluzky
Copy link
Owner

bluzky commented Nov 22, 2024

Thanks @selenil, I'm quite busy for a few days, I'll come back to this PR next week

@bluzky bluzky changed the base branch from main to feature/accessibility-hook November 28, 2024 15:37
@bluzky bluzky merged commit a9752bc into bluzky:feature/accessibility-hook Nov 28, 2024
@bluzky
Copy link
Owner

bluzky commented Nov 28, 2024

Hi @selenil, I saw you import components from index but I didn't see index file in your commit

@selenil
Copy link
Contributor Author

selenil commented Nov 28, 2024

Hi @selenil, I saw you import components from index but I didn't see index file in your commit

Hi @bluzky, the index.js file is automatically generated by the salad.add task when a user installs a component for the first time. It is then updated each time a new component is added. Alternatively, we could generate this file earlier using the salad.init task and include a comment explaining its purpose.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants