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

WIP Integrate Zag.js to improve acessibility #104

Draft
wants to merge 64 commits into
base: main
Choose a base branch
from

Conversation

bluzky
Copy link
Owner

@bluzky bluzky commented Dec 7, 2024

Updated component

Some components are so simple so I don't think they need Zagjs dependencies

  • Accordion
  • Collapsible
  • Dialog D
  • Dropdown Menu D
  • Hover Card
  • Menu
  • Popover
  • Progress
  • Radio Group D
  • Select
  • Slider
  • ❌ Switch D currently, it works fine
  • Tabs
  • Toggle Group D
  • Tooltip D

Copy link

sourcery-ai bot commented Dec 7, 2024

Reviewer's Guide by Sourcery

This PR integrates Zag.js to improve accessibility in the UI components, starting with the Collapsible component. The implementation includes setting up Zag.js infrastructure, modifying the existing Collapsible component to use Zag.js, and adding necessary build and initialization tasks.

Class diagram for new ZagHook component

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, isRoot)
    }
    class ZagHook {
        +mounted()
        +updated()
        +destroyed()
        +context()
    }
    Component <|-- ZagHook
    note for Component "Handles component lifecycle and rendering with Zag.js"
Loading

File-Level Changes

Change Details Files
Refactored Collapsible component to use Zag.js instead of custom JavaScript
  • Replaced custom data attributes and Phoenix.JS with Zag.js data attributes
  • Modified default open state to false
  • Added support for listeners through a new attribute
  • Simplified trigger and content markup to work with Zag.js
lib/salad_ui/collapsible.ex
Added Zag.js integration infrastructure
  • Created ZagHook for Phoenix LiveView integration
  • Added utility functions for component initialization and prop normalization
  • Implemented component lifecycle management
  • Added event handling and DOM attribute management
priv/static/assets/zag/ZagHook.js
priv/static/assets/zag/utils.js
Enhanced build and initialization tasks
  • Added Zag.js setup to component installation process
  • Implemented automatic NPM package installation for Zag components
  • Added Zag files copying during project initialization
  • Added helper function for generating unique IDs
lib/mix/tasks/salad.add.ex
lib/mix/tasks/salad.init.ex
lib/salad_ui/helpers.ex

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

@bluzky bluzky changed the title Integrate Zag.js to improve acessibility WIP Integrate Zag.js to improve acessibility Dec 7, 2024
@bluzky bluzky marked this pull request as draft December 7, 2024 13:58
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 @bluzky - I've reviewed your changes - here's some feedback:

Overall Comments:

  • Please document the breaking changes in this PR, particularly the change of default collapsible state from open to closed. Consider adding migration notes for existing users.
Here's what I looked at during the review
  • 🟡 General issues: 2 issues 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.


continue? = Mix.shell().yes?("Do you want to continue with the installation?")

if continue?, do: :ok, else: {:error, "Installation aborted"}
Copy link

Choose a reason for hiding this comment

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

issue: Error handling should clean up any partial installation when user aborts

Consider adding cleanup logic to remove any files or changes made before the abort

this.component = new Component(this.el, this.context());
this.component.init();
} catch (error) {
console.error("Error mounting component:", error);
Copy link

Choose a reason for hiding this comment

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

issue (bug_risk): Error in mounted() should prevent further execution to avoid inconsistent state

Consider re-throwing the error or implementing proper error recovery to maintain component consistency

import * as componentsModules from "./index";
import { camelize, getBooleanOption, getOption, normalizeProps } from "./utils";

class Component {
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 extracting prop and event handling logic into dedicated PropManager and EventManager classes to better organize the Component class.

The Component class could be simplified by extracting prop and event management into separate classes. This maintains coordination while reducing complexity. Consider:

class PropManager {
  constructor(el) {
    this.el = el;
    this.prevAttrsMap = new WeakMap();
  }

  applyProps(attrs, isRoot = false) {
    const oldAttrs = this.prevAttrsMap.get(this.el) || {};

    Object.entries(attrs).forEach(([key, value]) => {
      if (!key.startsWith('on')) {
        this.applyAttribute(key, value, oldAttrs[key], isRoot);
      }
    });

    this.prevAttrsMap.set(this.el, attrs);
  }
}

class EventManager {
  constructor(el) {
    this.el = el;
    this.handlers = new Map();
  }

  bindEvents(attrs) {
    Object.entries(attrs)
      .filter(([key]) => key.startsWith('on'))
      .forEach(([key, handler]) => {
        const eventName = key.substring(2).toLowerCase();
        this.bindEvent(eventName, handler);
      });
  }

  cleanup() {
    this.handlers.forEach((handler, event) => {
      this.el.removeEventListener(event, handler);
    });
    this.handlers.clear();
  }
}

Then simplify Component to use these:

class Component {
  constructor(el, context) {
    this.el = el;
    this.context = context;
    this.propManager = new PropManager(el);
    this.eventManager = new EventManager(el);
  }

  spreadProps(el, attrs, isRoot = false) {
    this.propManager.applyProps(attrs, isRoot);
    this.eventManager.bindEvents(attrs);

    return () => this.eventManager.cleanup();
  }
}

This separation:

  • Makes the code more testable and maintainable
  • Reduces cognitive load by separating concerns
  • Keeps coordination in the Component class
  • Makes error handling clearer with focused responsibilities

}

renderItem(item) {
const value = item.dataset.value;
Copy link

Choose a reason for hiding this comment

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

suggestion (code-quality): Prefer object destructuring when accessing and using properties. (use-object-destructuring)

Suggested change
const value = item.dataset.value;
const {value} = item.dataset;


ExplanationObject destructuring can often remove an unnecessary temporary reference, as well as making your code more succinct.

From the Airbnb Javascript Style Guide

const apply = (attrName) => {
// avoid replacing id on root element because LiveView
// will lose track of it and DOM patching will not work as expected
if (attrName === "id" && isRoot) return;
Copy link

Choose a reason for hiding this comment

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

suggestion (code-quality): Use block braces for ifs, whiles, etc. (use-braces)

Suggested change
if (attrName === "id" && isRoot) return;
if (attrName === "id" && isRoot) {


ExplanationIt is recommended to always use braces and create explicit statement blocks.

Using the allowed syntax to just write a single statement can lead to very confusing
situations, especially where subsequently a developer might add another statement
while forgetting to add the braces (meaning that this wouldn't be included in the condition).

let value = attrs[attrName];
const oldValue = oldAttrs[attrName];

if (value === oldValue) return;
Copy link

Choose a reason for hiding this comment

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

suggestion (code-quality): Use block braces for ifs, whiles, etc. (use-braces)

Suggested change
if (value === oldValue) return;
if (value === oldValue) {


ExplanationIt is recommended to always use braces and create explicit statement blocks.

Using the allowed syntax to just write a single statement can lead to very confusing
situations, especially where subsequently a developer might add another statement
while forgetting to add the braces (meaning that this wouldn't be included in the condition).


if (value === oldValue) return;

if (typeof value === "boolean") value = value || undefined;
Copy link

Choose a reason for hiding this comment

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

suggestion (code-quality): Use block braces for ifs, whiles, etc. (use-braces)

Suggested change
if (typeof value === "boolean") value = value || undefined;
if (typeof value === "boolean") {


ExplanationIt is recommended to always use braces and create explicit statement blocks.

Using the allowed syntax to just write a single statement can lead to very confusing
situations, especially where subsequently a developer might add another statement
while forgetting to add the braces (meaning that this wouldn't be included in the condition).

Comment on lines 183 to 184
if (key.startsWith("on")) setup(key);
else apply(key);
Copy link

Choose a reason for hiding this comment

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

suggestion (code-quality): Use block braces for ifs, whiles, etc. (use-braces)

Suggested change
if (key.startsWith("on")) setup(key);
else apply(key);
if (key.startsWith("on")) {
setup(key);
} else apply(key);


ExplanationIt is recommended to always use braces and create explicit statement blocks.

Using the allowed syntax to just write a single statement can lead to very confusing
situations, especially where subsequently a developer might add another statement
while forgetting to add the braces (meaning that this wouldn't be included in the condition).


export const normalizeProps = createNormalizer((props) => {
return Object.entries(props).reduce((acc, [key, value]) => {
if (value === undefined) return acc;
Copy link

Choose a reason for hiding this comment

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

suggestion (code-quality): Use block braces for ifs, whiles, etc. (use-braces)

Suggested change
if (value === undefined) return acc;
if (value === undefined) {


ExplanationIt is recommended to always use braces and create explicit statement blocks.

Using the allowed syntax to just write a single statement can lead to very confusing
situations, especially where subsequently a developer might add another statement
while forgetting to add the braces (meaning that this wouldn't be included in the condition).


export const toStyleString = (style) => {
return Object.entries(style).reduce((styleString, [key, value]) => {
if (value === null || value === undefined) return styleString;
Copy link

Choose a reason for hiding this comment

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

suggestion (code-quality): Use block braces for ifs, whiles, etc. (use-braces)

Suggested change
if (value === null || value === undefined) return styleString;
if (value === null || value === undefined) {


ExplanationIt is recommended to always use braces and create explicit statement blocks.

Using the allowed syntax to just write a single statement can lead to very confusing
situations, especially where subsequently a developer might add another statement
while forgetting to add the braces (meaning that this wouldn't be included in the condition).

@selenil
Copy link
Contributor

selenil commented Dec 8, 2024

@bluzky, we might need to consider adding support for Zag's collection API, rather than handling collections as an option. This would make the integration easier for components like select. I'd be happy to take this on and work on integrating Zag with other components as well. Let me know how I can contribute, and feel free to reach out if you need help with this PR.

@bluzky
Copy link
Owner Author

bluzky commented Dec 9, 2024

Thanks, @selenil, It's great if we can define a better way to handle collection. It's a bit hacky building collection when init Zag component.
Pls pick components that you can handle, so I and you won't overlap each other.
I think we should bump version to 1.0 after complete this PR, because there are too many changes, and they break the current api.

@selenil
Copy link
Contributor

selenil commented Dec 9, 2024

@bluzky I will take:

  • Accordion
  • Hover Card
  • Menu
  • Popover
  • Progress
  • Slider
  • Tabs

Also, we could now implement two new components using Zag's foundation: Combobox and Context Menu. I will also take Context Menu.

I will first work on supporting collections in the hook, and then I will move on to the components.

We can remove the following components from the list, as they aren't supported by Zag:

  • Alert
  • Alert Dialog
  • Badge
  • Breadcrumb
  • Card
  • Dropdown Menu
  • Scroll Area
  • Sheet
  • Skeleton
  • Sidebar
  • Toggle

These components are either too simple or don't have significant accessibility requirements (with a few exceptions). In my view, these components are essentially complete, needing only improvements in documentation and/or minor accessibility attributes.

The unique_id helper from this PR can also be removed. I originally added this function to generate unique IDs since hooks require an ID, and it would have been strange to ask users to provide an ID for elements like buttons. However, at this point, the ZagHook will only be used in highly interactive components, where it’s reasonable to ask users to provide an ID. This helper adds unnecessary complexity and cannot provide persistent IDs, so the IDs get rewritten every time the UI updates.

@bluzky
Copy link
Owner Author

bluzky commented Dec 9, 2024

@selenil that's great, the list is now much shorter. I've updated JS code which I fix some issues when testing on Storybook

@selenil
Copy link
Contributor

selenil commented Dec 26, 2024

@bluzky the hook handles all interactivity, but it won't work properly if the component uses classes like hidden to control interactive behaviors. Components should only contain styles and markup, while the hook automatically manages all interaction logic. The functions to interact imperatively with the component using JS commands should be kept as public functions, so users can use them if they need to.

We could also provide a system to react to certain events in the component with custom code. Zag emits events when interactions happen with the component. The idea here is to ask users for the events they want to react and where they want to handle them (client, server or both) via a listeners attribute. Then, the hook just dispatches and/or pushes those events when they happen. By doing that, users now can execute their own code when those events happen. In the client, they can use window.addEventListener and in the server they can use handle_event/3. Note that in this way the hook is not executing any custom code and the users still have complete control over how to react to the events.

About the syntax, we could use a map or a list of tuples.

def render(assigns) do
 ~H"""
 <.some_component listeners={%{on_open_change: [:client, :server], on_value_change: [:client]}}></.some_component>
 """
end

def render(assigns) do
  ~H"""
  <.some_component listeners={[{:on_value_change, :client, :server}, {:on_value_change, :client}]}></.some_component>
  """
end

Let me know what you think about this.

@bluzky
Copy link
Owner Author

bluzky commented Jan 7, 2025

Thanks @selenil, I've just come back from my holiday.
My concern is how to handle client side event. Does user have to add js code to listen to event?
In my opinion, I don't want user to touch js code if we can somehow handle it in SaladUI itself.

@selenil
Copy link
Contributor

selenil commented Jan 7, 2025

Thanks @selenil, I've just come back from my holiday. My concern is how to handle client side event. Does user have to add js code to listen to event? In my opinion, I don't want user to touch js code if we can somehow handle it in SaladUI itself.

The component's interaction logic is handled by Zag; we don't need extra js code. Beyond that, Zag also allows to execute custom js code when certain events occur.

For example, in the tabs component, all the logic to change active tabs and display content is provided by Zag and will work without any custom js code other than the hook itself. In addition, Zag allows to execute custom js code when the selected tab changes and when the focused tab changes. Instead of asking users to write custom code directly in the component, I propose a simpler approach: users can specify which events they want to react to and where (client-side, server-side, or both). The hook then simply needs to dispatch and/or push the event.

Users can listen for these events using window.addEventListener on the client side or handle_event/3 on the server side, allowing them to implement their own logic aside from the component's logic. For example, maybe a user wants to disable a checkbox in the UI depending on the selected option in a dropdown.

This event system is entirely optional – the component works perfectly fine without any custom code. This is to offer users the flexibility to extend the component's functionality and respond to specific interactions as needed.

I'll open a PR in the next days implementing it.

@bluzky
Copy link
Owner Author

bluzky commented Jan 8, 2025

@selenil yeah I got your idea.

@selenil selenil mentioned this pull request Jan 19, 2025
@selenil
Copy link
Contributor

selenil commented Jan 24, 2025

@bluzky we can remove the menu component from the list; the menu that Zag supports is really a dropdown and our menu is a statically menu.

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