spartan-ng / spartan

Cutting-edge tools powering Angular full-stack development.
https://spartan.ng
MIT License
1.47k stars 156 forks source link

RFC: Alert Dialog #4

Closed goetzrobin closed 1 year ago

goetzrobin commented 1 year ago

brn

API (proposed)

<brn-alert-dialog>
  <button brnAlertDialogTrigger>Open</button>
  <div *brnAlertDialogContent>
    <brn-alert-dialog-header>
      <h3 brnAlertDialogTitle>Are you absolutely sure?</h3> <!-- could also just make it a brn-alert-dialog-title component -->
      <p brnAlertDialogDescription>  <!-- could also just make it a brn-alert-dialog-description component -->
        This action cannot be undone. This will permanently delete your account
        and remove your data from our servers.
      </p>
    </brn-alert-dialog-header>
    <brn-alert-dialog-footer>
      <button brnAlertDialogCancel>Cancel</button> <!-- or we expose a context, ctx variable in the template to do something like (click)="ctx.close()" -->
      <button brnAlertDialogAction>Continue</button> <!-- or we expose a context, ctx variable in the template to do something like (click)="ctx.action(anyDataYouWantToExposeOnSuccess)" -->
    </brn-alert-dialog-footer>
  </div>
</brn-alert-dialog>

Do we like the overlay being its own component? I could also see this being a structural directive that allows for some configuration through inputs it directly?

Helpful info

Dialog also allows to expose data to the dialog through dependency injection. I don't have anything against it, but might not even need that if we go the template route since we also have access to data/functions of the enclosing component. We do have to be aware of Change Detection implications of this approach though as CD of enclosing component is used for dialog in this case, as far as I understand.

hlm

API (proposed)

<hlm-alert-dialog>
  <button hlmAlertDialogTrigger>Open</button>
  <div *hlmAlertDialogContent>
    <hlm-alert-dialog-header>
      <h3 hlmAlertDialogTitle>Are you absolutely sure?</h3> <!-- could also just make it a brn-alert-dialog-title component -->
      <p hlmAlertDialogDescription>  <!-- could also just make it a brn-alert-dialog-description component -->
        This action cannot be undone. This will permanently delete your account
        and remove your data from our servers.
      </p>
    </hlm-alert-dialog-header>
    <hlm-alert-dialog-footer>
      <button hlmAlertDialogCancel>Cancel</button> <!-- or we expose a context, ctx variable in the template to do something like (click)="ctx.close()" -->
      <button hlmAlertDialogAction>Continue</button> <!-- or we expose a context, ctx variable in the template to do something like (click)="ctx.action(anyDataYouWantToExposeOnSuccess)" -->
    </hlm-alert-dialog-footer>
  </div>
</hlm-alert-dialog>

Since this is a pretty complex component I would just expose the same API, but with a hlm prefix and maybe also expose some standalone directives that only apply the styles for all the different parts. We should aim for simplicity as most people might not need the available customization brought by the component + (optional style) directive, but instead want the simplest API

Helpful info


radix

API

import * as AlertDialog from '@radix-ui/react-alert-dialog';

export default () => (
  <AlertDialog.Root>
    <AlertDialog.Trigger />
    <AlertDialog.Portal>
      <AlertDialog.Overlay />
      <AlertDialog.Content>
        <AlertDialog.Title />
        <AlertDialog.Description />
        <AlertDialog.Cancel />
        <AlertDialog.Action />
      </AlertDialog.Content>
    </AlertDialog.Portal>
  </AlertDialog.Root>
);

Source

https://github.com/radix-ui/primitives/tree/main/packages/react/alert-dialog/src

Aria Design Pattern

Source

https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/

WAI-ARIA Roles, States, and Properties

Note

  1. When a dialog opens, focus moves to an element contained in the dialog. Generally, focus is initially set on the first focusable element. However, the most appropriate focus placement will depend on the nature and size of the content. Examples include:
    • If the dialog content includes semantic structures, such as lists, tables, or multiple paragraphs, that need to be perceived in order to easily understand the content, i.e., if the content would be difficult to understand when announced as a single unbroken string, then it is advisable to add tabindex="-1" to a static element at the start of the content and initially focus that element. This makes it easier for assistive technology users to read the content by navigating the semantic structures. Additionally, it is advisable to omit applying aria-describedby to the dialog container in this scenario.
    • If content is large enough that focusing the first interactive element could cause the beginning of content to scroll out of view, it is advisable to add tabindex="-1" to a static element at the top of the dialog, such as the dialog title or first paragraph, and initially focus that element.
    • If a dialog contains the final step in a process that is not easily reversible, such as deleting data or completing a financial transaction, it may be advisable to set focus on the least destructive action, especially if undoing the action is difficult or impossible. The Alert Dialog Pattern is often employed in such circumstances.
    • If a dialog is limited to interactions that either provide additional information or continue processing, it may be advisable to set focus to the element that is likely to be most frequently used, such as an OK or Continue button.
  2. When a dialog closes, focus returns to the element that invoked the dialog unless either:
    • The invoking element no longer exists. Then, focus is set on another element that provides logical work flow.
    • The work flow design includes the following conditions that can occasionally make focusing a different element a more logical choice:
      1. It is very unlikely users need to immediately re-invoke the dialog.
      2. The task completed in the dialog is directly related to a subsequent step in the work flow.
    • For example, a grid has an associated toolbar with a button for adding rows. The Add Rows button opens a dialog that prompts for the number of rows. After the dialog closes, focus is placed in the first cell of the first new row.
  3. It is strongly recommended that the tab sequence of all dialogs include a visible element with role button that closes the dialog, such as a close icon or cancel button.

    Helpful info

shadcn

API

<AlertDialog>
  <AlertDialogTrigger>Open</AlertDialogTrigger>
  <AlertDialogContent>
    <AlertDialogHeader>
      <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
      <AlertDialogDescription>
        This action cannot be undone. This will permanently delete your account
        and remove your data from our servers.
      </AlertDialogDescription>
    </AlertDialogHeader>
    <AlertDialogFooter>
      <AlertDialogCancel>Cancel</AlertDialogCancel>
      <AlertDialogAction>Continue</AlertDialogAction>
    </AlertDialogFooter>
  </AlertDialogContent>
</AlertDialog>

Source

"use client"

import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"

import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"

const AlertDialog = AlertDialogPrimitive.Root

const AlertDialogTrigger = AlertDialogPrimitive.Trigger

const AlertDialogPortal = ({
  className,
  children,
  ...props
}: AlertDialogPrimitive.AlertDialogPortalProps) => (
  <AlertDialogPrimitive.Portal className={cn(className)} {...props}>
    <div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center">
      {children}
    </div>
  </AlertDialogPrimitive.Portal>
)
AlertDialogPortal.displayName = AlertDialogPrimitive.Portal.displayName

const AlertDialogOverlay = React.forwardRef<
  React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, children, ...props }, ref) => (
  <AlertDialogPrimitive.Overlay
    className={cn(
      "fixed inset-0 z-50 bg-background/80 backdrop-blur-sm transition-opacity animate-in fade-in",
      className
    )}
    {...props}
    ref={ref}
  />
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName

const AlertDialogContent = React.forwardRef<
  React.ElementRef<typeof AlertDialogPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
  <AlertDialogPortal>
    <AlertDialogOverlay />
    <AlertDialogPrimitive.Content
      ref={ref}
      className={cn(
        "fixed z-50 grid w-full max-w-lg scale-100 gap-4 border bg-background p-6 opacity-100 shadow-lg animate-in fade-in-90 slide-in-from-bottom-10 sm:rounded-lg sm:zoom-in-90 sm:slide-in-from-bottom-0 md:w-full",
        className
      )}
      {...props}
    />
  </AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName

const AlertDialogHeader = ({
  className,
  ...props
}: React.HTMLAttributes<HTMLDivElement>) => (
  <div
    className={cn(
      "flex flex-col space-y-2 text-center sm:text-left",
      className
    )}
    {...props}
  />
)
AlertDialogHeader.displayName = "AlertDialogHeader"

const AlertDialogFooter = ({
  className,
  ...props
}: React.HTMLAttributes<HTMLDivElement>) => (
  <div
    className={cn(
      "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
      className
    )}
    {...props}
  />
)
AlertDialogFooter.displayName = "AlertDialogFooter"

const AlertDialogTitle = React.forwardRef<
  React.ElementRef<typeof AlertDialogPrimitive.Title>,
  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
  <AlertDialogPrimitive.Title
    ref={ref}
    className={cn("text-lg font-semibold", className)}
    {...props}
  />
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName

const AlertDialogDescription = React.forwardRef<
  React.ElementRef<typeof AlertDialogPrimitive.Description>,
  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
  <AlertDialogPrimitive.Description
    ref={ref}
    className={cn("text-sm text-muted-foreground", className)}
    {...props}
  />
))
AlertDialogDescription.displayName =
  AlertDialogPrimitive.Description.displayName

const AlertDialogAction = React.forwardRef<
  React.ElementRef<typeof AlertDialogPrimitive.Action>,
  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
  <AlertDialogPrimitive.Action
    ref={ref}
    className={cn(buttonVariants(), className)}
    {...props}
  />
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName

const AlertDialogCancel = React.forwardRef<
  React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
  <AlertDialogPrimitive.Cancel
    ref={ref}
    className={cn(
      buttonVariants({ variant: "outline" }),
      "mt-2 sm:mt-0",
      className
    )}
    {...props}
  />
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName

export {
  AlertDialog,
  AlertDialogTrigger,
  AlertDialogContent,
  AlertDialogHeader,
  AlertDialogFooter,
  AlertDialogTitle,
  AlertDialogDescription,
  AlertDialogAction,
  AlertDialogCancel,
}

Other notes

This should be connected to the RFC of the general Dialog once it is created

mihajm commented 1 year ago

Hard one to start with, due to dialog considerations :) I like the directive approach to dialog-title, description etc. I'd honestly take it further and make the entire thing a structural directive as this would allow us to pass context to the entire component & extend it, remove the internal button from it's content projection & make the current *dialogContent directive unnecessary.

Single component example:

<button [brnDialogTriggerFor]="myAlert" />

<div #myAlert *brnDialog="data; let ctx=context;" />
    <!-- dialog content -->
</div>

This would also allow for a constructor based approach a la angular cdk

@Component({
  // dialog
})
class DialogComponent {
  private readonly ctx = inject(BrnDialogContext);
}

@Component({
   template: `
   <button (click)="openDialog()" />Open</button>
   `
})
class ParentComponent {
   private readonly dialog = inject(BrnDialog);

   openDialog() {
      this.dialog.open(DialogComponent, {
         // options,
        data
      })
   }
}

We could separate data & the dialog ref into separate injection tokens/passed context props or have them on the same object as I think most use both when using injection

interface BrnDialogRef<Output = unknown> {
   close(output?: Output): void;
   afterClosed(): Observable<Output | undefined>
   // other relevant fns
}

type BrnDialogContext<Input = unknown, Output = unknown> {
   readonly $implicit: Input;
   readonly ref: BrnDialogRef<Output>;
   readonly data: Input // getter to $implicit for easier typing within .ts files
}

Then the alert can either be done as a simple alert component, composed with the dialog component itself or as syntax sugar for the dialog directive through extension. Functionally it would do the same.

I'm a bit unsure on the separation of Cancel & Action buttons, maybe an angular material like approach with a simple [brn-dialog-close]="outputData" would suffice?

The overlay i don't mind either way, since tw would allow for any reasonable customization through background color, opacity.. class props

goetzrobin commented 1 year ago

We could do that too for sure. Using the wrapper component could allow people to add classes to that component which are used to style the backdrop, and "hides" the manual wiring up of the trigger by using a reference to the directive exposed by "exportAs". I think that would make it more beginner friendly, but they're also not mutually exclusive.

If you have a wrapper it does the wiring up for you if you don't you'll need to do it yourself?

goetzrobin commented 1 year ago

Yeah I just looked at the radix UI code to see if any special aria roles are assigned by those close and action buttons and it does not seem so. I think it could be a convenience thing to add the directives, but with exposing the context it's absolutely not needed. Very open to drop them tbh

mihajm commented 1 year ago

Fair :) a wrapper it is.

Not really firm on this one either, personally id always use close, but we can always add more descriptive selectors to that directive