huntabyte / bits-ui

The headless components for Svelte.
https://bits-ui.com
MIT License
1.21k stars 88 forks source link

next: Clarify `Child Snippet` usage with pre-styled component. #723

Closed jjones315 closed 5 days ago

jjones315 commented 6 days ago

Change Type

Addition

Proposed Changes

While testing the bits-ui@next i ran in to some speed bumps i wanted to clarify, and also possibly improve guidance in docs. When using the tooltip with the child snippet for example, i want to have a pre-styled button as the trigger. for example a shadcn button where variants create a set of classes and they are not directly passed. but the child props are overriding the styling since they are spreading. My 2 solutions are to put the variants on the Tooltip Trigger and loose types since the render delegation is not generic, or modify my button component to receive "delegateProps" and use the mergeProps utility internally. i dont think either of those soltutions are ideal, since on one hand i have to modify all of my possible delegates, or lose my variant types. was wondering if you had guidance or plan to address situtions like these.

Thanks for any guidance (and this library) :)

huntabyte commented 6 days ago

Can you provide an example with the code of what you're trying to do? Happy to help.

huntabyte commented 6 days ago

I'm not really able to understand exactly what you're trying to do.

Do you mean something like this? @jjones315

<script lang="ts">
    import { Button } from '$lib/components/ui/button'
    import { Tooltip } from 'bits-ui'
</script>

<Tooltip.Root>
    <Tooltip.Trigger>
        {#snippet child({ props })}
            <Button variant="ghost" {...props}>
                Hover to open
            </Button>
        {/snippet}
    </Tooltip.Trigger>
</Tooltip.Root>
jjones315 commented 6 days ago

Sorry, i meant to add an example. brain isn't working today 😅.

yes that is effectively what i have. Button is an internal based of next-ui styles, but shadcn works fine as a substitute, its using tallwind-variants to create classes from variants.

Toolstip.svelte

<script lang="ts">
    import { cn } from "$ui/utils";
    import { Tooltip } from "bits-ui";
    import type { TooltipProps } from ".";

    let {
        delayDuration = 300,
        skipDelayDuration,
        disableHoverableContent,
        disableCloseOnTriggerClick,
        disabled,
        ignoreNonKeyboardFocus,

        // root
        open = $bindable(false),
        onOpenChange,

        portal = "body",

        // content
        content,
        contentProps,
        side = "bottom",
        sideOffset = 10,
        transition,

        // trigger
        triggerRef = $bindable(),
        class: className,
        classes,
        ...otherProps
    }: TooltipProps = $props();
</script>

<Tooltip.Provider
    {delayDuration}
    {disableCloseOnTriggerClick}
    {disableHoverableContent}
    {disabled}
    {ignoreNonKeyboardFocus}
    {skipDelayDuration}
>
    <Tooltip.Root
        {delayDuration}
        {disableCloseOnTriggerClick}
        {disableHoverableContent}
        {disabled}
        {ignoreNonKeyboardFocus}
        {onOpenChange}
        bind:open
    >
        <Tooltip.Trigger class={cn("outline-none", className, classes?.trigger)} tabindex={-1} {...otherProps} />
        {#if content}
            <Tooltip.Portal to={portal}>
                <Tooltip.Content
                    style="transform: translate({open ? 0 : 8}px);"
                    class={cn(
                        "z-[100000] rounded-lg border bg-content1 p-2 text-sm text-foreground shadow-md transition-all",
                        classes?.content,
                        {
                            "scale-80 opacity-0": !open,
                            "translate-x-4": !open && side === "left",
                            "-translate-x-4": !open && side === "right",
                            "translate-y-4": !open && side === "top",
                            "-translate-y-4": !open && side === "bottom",
                        }
                    )}
                    {...contentProps}
                    forceMount
                    {side}
                    {sideOffset}
                >
                    {#if typeof content === "string"}
                        {content}
                    {:else}
                        {@render content()}
                    {/if}
                </Tooltip.Content>
            </Tooltip.Portal>
        {/if}
    </Tooltip.Root>
</Tooltip.Provider>

example usage

              <!-- works fine, but has type errors from button variants on Tooltip -->
              <Tooltip
                  class="text-neutral-500"
                  color="default"
                  content="Edit Email"
                  isIconOnly
                  onclick={handleEditEmail}
                  radius="full"
                  size="sm"
                  variant="light"
              >
                  {#snippet child({ props })}
                      <Button {...props}>
                          <Icon name="material-symbols/edit" class="text-xl" />
                      </Button>
                  {/snippet}
              </Tooltip>

                <!-- works fine, but have to modify every delegated component type to use mergeProps Internally -->
                <Tooltip
                    class="text-neutral-500"
                    content={showPasswordText ? "Hide Password" : "Show Password"}
                >
                    {#snippet child({ props })}
                        <Button
                            class="text-neutral-500"
                            as="span"
                            delegateProps={props}
                            isIconOnly
                            onclick={() => (showPasswordText = !showPasswordText)}
                            radius="full"
                            size="sm"
                            variant="light"
                        >
                            {#if showPasswordText}
                                <Icon
                                    name="material-symbols/visibility-off"
                                    class="pointer-events-none text-xl"
                                />
                            {:else}
                                <Icon name="material-symbols/visibility" class="pointer-events-none text-xl" />
                            {/if}
                        </Button>
                    {/snippet}
                </Tooltip>

                <!-- this was the initial conversion i had after updating bits-ui. onclick and class are obviously broken.  -->
                <Tooltip
                    class="text-neutral-500"
                    content={showPasswordText ? "Hide Password" : "Show Password"}
                >
                    {#snippet child({ props })}
                        <Button
                            class="text-neutral-500"
                            as="span"
                            isIconOnly
                            onclick={() => (showPasswordText = !showPasswordText)}
                            radius="full"
                            size="sm"
                            variant="light"
                            {...props}
                        >
                            {#if showPasswordText}
                                <Icon
                                    name="material-symbols/visibility-off"
                                    class="pointer-events-none text-xl"
                                />
                            {:else}
                                <Icon name="material-symbols/visibility" class="pointer-events-none text-xl" />
                            {/if}
                        </Button>
                    {/snippet}
                </Tooltip>

One other approach would be to put attributers on the Trigger and props on the Button, but having to explain that to every developer and onboard is going to be cumbersome and bug-ridden long term.

thanks for any advice

huntabyte commented 6 days ago

Oh, here you just need to modify the type you're accepting at the top level:

<script lang="ts">
    import { cn } from "$ui/utils";
    import { Tooltip } from "bits-ui";
    import type { TooltipProps } from ".";
    import type { ButtonVariants } from '$ui/somewhere'

    type MyTooltipProps = TooltipProps & {
        variant?: ButtonVariants;
        // any other props you want to be typed...
    }

    // or you could do something like this
    type MyTooltipProps = TooltipProps & ButtonProps

    let {
       // all your props
        ...otherProps
    }: MyTooltipProps = $props();
</script>
huntabyte commented 6 days ago

You're combining several different components into one. Why not just inside the Toolstip.svelte handle the delegation there instead of making consumers handle it outside each time? You could always handle it for them by default as the fallback, and if children is passed, then the custom whatever will render.

If you can link me a repo with some minimal code for this I'm happy to help make it click.

jjones315 commented 6 days ago

Yes, we use a lot of different components as triggers. Buttons, chips, styled links, avatars, etc… if we always used a button I would just embed it as you mentioned. I can try to work out a minimal repo, but not sure how beneficial it will be. Since it effectively the code about with button replaces with several other things.

I know you mentioned the fact I bundled the tooltip in to a component, not sure that changes the paradigm here? The same issues would arise with them separate? Not sure if I’m missing something there

jjones315 commented 6 days ago

I could make my tooltip generic and to get type safe props at the tooltip level. Any thought about adding generics at a library level or do you think it’s best to leave it to user land?

huntabyte commented 5 days ago

Can you elaborate on what mean by generics at the library level? What would these generics be applied to?