hsuanyi-chou / shadcn-ui-expansions

More components built on top of shadcn-ui.
https://shadcnui-expansions.typeart.cc/
MIT License
835 stars 38 forks source link

More accessible using `Popover` #23

Open mamlzy opened 8 months ago

mamlzy commented 8 months ago

It will be more accessible if using Popover. Without Popover, it's always give additional height if I put the input at the very bottom of the screen. Thank you for your work!

flipvh commented 5 months ago

Hi @ImamAlfariziSyahputra, I think you are right. Have you by any chance worked on this? Else I will myself probably :D. Thanks

Willienn commented 4 months ago

Im changing mine to use popover. its not finishing yet, but i think that wont be too much diferent:

<Popover>
        <div className="flex flex-col">
          <div>
            {selected.map((option) => (
              <Badge
                key={option.value}
                className={cn(
                  "data-[disabled]:bg-muted-foreground data-[disabled]:text-muted data-[disabled]:hover:bg-muted-foreground",
                  "data-[fixed]:bg-muted-foreground data-[fixed]:text-muted data-[fixed]:hover:bg-muted-foreground",
                  badgeClassName
                )}
                data-fixed={option.fixed}
                data-disabled={disabled}
              >
                {option.label}
                <button
                  className={cn(
                    "ring-offset-background focus:ring-ring ml-1 rounded-full outline-none focus:ring-2 focus:ring-offset-2",
                    (disabled || option.fixed) && "hidden"
                  )}
                  onKeyDown={(e) => {
                    if (e.key === "Enter") {
                      handleUnselect(option)
                    }
                  }}
                  onMouseDown={(e) => {
                    e.preventDefault()
                    e.stopPropagation()
                  }}
                  onClick={() => handleUnselect(option)}
                >
                  <X className="text-muted-foreground hover:text-foreground h-3 w-3" />
                </button>
              </Badge>
            ))}
          </div>

          <Command
            {...commandProps}
            onKeyDown={(e) => {
              handleKeyDown(e)
              commandProps?.onKeyDown?.(e)
            }}
            className={cn(
              "overflow-visible bg-transparent",
              commandProps?.className
            )}
            shouldFilter={
              commandProps?.shouldFilter !== undefined
                ? commandProps.shouldFilter
                : !onSearch
            } // When onSearch is provided, we don't want to filter the options. You can still override it.
            filter={commandFilter()}
          >
            <PopoverTrigger>
              <Input
                className="px-2"
                value={inputValue}
                disabled={disabled}
                onChange={(e) => {
                  setInputValue(e.target.value)
                  inputProps?.onValueChange?.(e.target.value)
                }}
                onBlur={(event) => {
                  setOpen(false)
                  inputProps?.onBlur?.(event)
                }}
                onFocus={(event) => {
                  setOpen(true)
                  triggerSearchOnFocus && onSearch?.(debouncedSearchTerm)
                  inputProps?.onFocus?.(event)
                }}
                placeholder={
                  hidePlaceholderWhenSelected && selected.length !== 0
                    ? ""
                    : placeholder
                }
              />
            </PopoverTrigger>
            <CommandPrimitive.Input
              {...inputProps}
              ref={inputRef}
              value={inputValue}
              className={cn("hidden", inputProps?.className)}
            />
            <PopoverContent
              autoFocus={false}
              asChild
              onOpenAutoFocus={(e) => e.preventDefault()}
            >
              <CommandList className=" w-full rounded-md border shadow-md outline-none animate-in">
                {isLoading ? (
                  <>{loadingIndicator}</>
                ) : (
                  <>
                    {EmptyItem()}
                    {CreatableItem()}
                    {!selectFirstItem && (
                      <CommandItem value="-" className="hidden" />
                    )}
                    {Object.entries(selectables).map(([key, dropdowns]) => (
                      <CommandGroup key={key} heading={key} className="h-full">
                        {dropdowns.map((option) => (
                          <CommandItem
                            key={option.value}
                            value={option.value}
                            disabled={option.disable}
                            onMouseDown={(e) => {
                              e.preventDefault()
                              e.stopPropagation()
                            }}
                            onSelect={() => {
                              if (selected.length >= maxSelected) {
                                onMaxSelected?.(selected.length)
                                return
                              }
                              setInputValue("")
                              const newOptions = [...selected, option]
                              setSelected(newOptions)
                              onChange?.(newOptions)
                            }}
                            className={cn(
                              "cursor-pointer",
                              option.disable &&
                                "text-muted-foreground cursor-default"
                            )}
                          >
                            {option.label}
                          </CommandItem>
                        ))}
                      </CommandGroup>
                    ))}
                  </>
                )}
              </CommandList>
            </PopoverContent>
          </Command>
        </div>
      </Popover>
zahidiqbalnbs commented 4 months ago

Im changing mine to use popover. its not finishing yet, but i think that wont be too much diferent:

<Popover>
        <div className="flex flex-col">
          <div>
            {selected.map((option) => (
              <Badge
                key={option.value}
                className={cn(
                  "data-[disabled]:bg-muted-foreground data-[disabled]:text-muted data-[disabled]:hover:bg-muted-foreground",
                  "data-[fixed]:bg-muted-foreground data-[fixed]:text-muted data-[fixed]:hover:bg-muted-foreground",
                  badgeClassName
                )}
                data-fixed={option.fixed}
                data-disabled={disabled}
              >
                {option.label}
                <button
                  className={cn(
                    "ring-offset-background focus:ring-ring ml-1 rounded-full outline-none focus:ring-2 focus:ring-offset-2",
                    (disabled || option.fixed) && "hidden"
                  )}
                  onKeyDown={(e) => {
                    if (e.key === "Enter") {
                      handleUnselect(option)
                    }
                  }}
                  onMouseDown={(e) => {
                    e.preventDefault()
                    e.stopPropagation()
                  }}
                  onClick={() => handleUnselect(option)}
                >
                  <X className="text-muted-foreground hover:text-foreground h-3 w-3" />
                </button>
              </Badge>
            ))}
          </div>

          <Command
            {...commandProps}
            onKeyDown={(e) => {
              handleKeyDown(e)
              commandProps?.onKeyDown?.(e)
            }}
            className={cn(
              "overflow-visible bg-transparent",
              commandProps?.className
            )}
            shouldFilter={
              commandProps?.shouldFilter !== undefined
                ? commandProps.shouldFilter
                : !onSearch
            } // When onSearch is provided, we don't want to filter the options. You can still override it.
            filter={commandFilter()}
          >
            <PopoverTrigger>
              <Input
                className="px-2"
                value={inputValue}
                disabled={disabled}
                onChange={(e) => {
                  setInputValue(e.target.value)
                  inputProps?.onValueChange?.(e.target.value)
                }}
                onBlur={(event) => {
                  setOpen(false)
                  inputProps?.onBlur?.(event)
                }}
                onFocus={(event) => {
                  setOpen(true)
                  triggerSearchOnFocus && onSearch?.(debouncedSearchTerm)
                  inputProps?.onFocus?.(event)
                }}
                placeholder={
                  hidePlaceholderWhenSelected && selected.length !== 0
                    ? ""
                    : placeholder
                }
              />
            </PopoverTrigger>
            <CommandPrimitive.Input
              {...inputProps}
              ref={inputRef}
              value={inputValue}
              className={cn("hidden", inputProps?.className)}
            />
            <PopoverContent
              autoFocus={false}
              asChild
              onOpenAutoFocus={(e) => e.preventDefault()}
            >
              <CommandList className=" w-full rounded-md border shadow-md outline-none animate-in">
                {isLoading ? (
                  <>{loadingIndicator}</>
                ) : (
                  <>
                    {EmptyItem()}
                    {CreatableItem()}
                    {!selectFirstItem && (
                      <CommandItem value="-" className="hidden" />
                    )}
                    {Object.entries(selectables).map(([key, dropdowns]) => (
                      <CommandGroup key={key} heading={key} className="h-full">
                        {dropdowns.map((option) => (
                          <CommandItem
                            key={option.value}
                            value={option.value}
                            disabled={option.disable}
                            onMouseDown={(e) => {
                              e.preventDefault()
                              e.stopPropagation()
                            }}
                            onSelect={() => {
                              if (selected.length >= maxSelected) {
                                onMaxSelected?.(selected.length)
                                return
                              }
                              setInputValue("")
                              const newOptions = [...selected, option]
                              setSelected(newOptions)
                              onChange?.(newOptions)
                            }}
                            className={cn(
                              "cursor-pointer",
                              option.disable &&
                                "text-muted-foreground cursor-default"
                            )}
                          >
                            {option.label}
                          </CommandItem>
                        ))}
                      </CommandGroup>
                    ))}
                  </>
                )}
              </CommandList>
            </PopoverContent>
          </Command>
        </div>
      </Popover>

Were you able to make it functional with desired multiselect features?

Willienn commented 4 months ago

@zahidiqbalnbs Yep! Its works very well for me, here the demo. We need to fix a bug in the docs but you can see how it works

muten84 commented 3 months ago

Hi @flipvh @mamlzy @zahidiqbalnbs @Willienn @tabarra i have created a version using the popover and i have deleted some divs to enable overlay between button used within the popover trigger and the input used to search elements this is my latest version .... IMHO for me it works well if someone like it and when i have time i can make a pull request, let me know guys if it will be useful for you

<Popover open={open} modal={false} onOpenChange={setOpen}>
        <PopoverTrigger asChild>
          <Button
            variant="ghost_neutral"
            role="combobox"
            aria-expanded={open}
            onClick={() => {
              if (disabled) return
              inputRef.current?.focus()
            }}
            className={cn(
              open ? 'opacity-0 h-[0px] mb-2' : 'opacity-100 h-auto',
              'transition-all ease-in-out duration-200 font-body dark:text-dark-neutral-200 w-full justify-between border border-neutral-100 pl-3 text-left text-neutral-950 hover:border-neutral-400 hover:bg-transparent dark:border-neutral-400 dark:hover:border-neutral-50 dark:hover:bg-transparent dark:hover:text-neutral-50'
            )}
          >
            <div className="flex h-2 items-center">
              {isEmpty(selected)
                ? placeholder
                : selected.map((s) => s.label).join(', ')}
            </div>
            <ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
          </Button>
        </PopoverTrigger>
        <PopoverContent
          sticky={'always'}
          align={'start'}
          sideOffset={-20}
          alignOffset={10}
          collisionPadding={10}
          className="popover-content-width-full m-0 p-0"
        >
          <Command
            {...commandProps}
            onKeyDown={(e) => {
              handleKeyDown(e)
              commandProps?.onKeyDown?.(e)
            }}
            className={cn('w-full overflow-visible', commandProps?.className)}
            shouldFilter={
              commandProps?.shouldFilter !== undefined
                ? commandProps.shouldFilter
                : !onSearch
            } // When onSearch is provided, we don't want to filter the options. You can still override it.
            filter={commandFilter()}
          >
            <div
              className={cn(
                'h-10 rounded-md border border-input text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2',
                {
                  'px-3 py-2': selected.length !== 0,
                  'cursor-text': !disabled && selected.length !== 0
                },
                className
              )}
              onClick={() => {
                if (disabled) return
                inputRef.current?.focus()
              }}
            >
              <div className="flex flex-wrap gap-1">
                {selected.map((option) => {
                  return (
                    <Badge
                      key={option.value}
                      variant={'select'}
                      className={cn(
                        'data-[disabled]:bg-muted-foreground data-[disabled]:text-muted data-[disabled]:hover:bg-muted-foreground',
                        'data-[fixed]:bg-muted-foreground data-[fixed]:text-muted data-[fixed]:hover:bg-muted-foreground',
                        badgeClassName
                      )}
                      data-fixed={option.fixed}
                      data-disabled={disabled || undefined}
                    >
                      {option.label}
                      <button
                        className={cn(
                          'ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2',
                          (disabled || option.fixed) && 'hidden'
                        )}
                        onKeyDown={(e) => {
                          if (e.key === 'Enter') {
                            handleUnselect(option)
                          }
                        }}
                        onMouseDown={(e) => {
                          e.preventDefault()
                          e.stopPropagation()
                        }}
                        onClick={() => handleUnselect(option)}
                      >
                        <X className="text-muted-foreground hover:text-foreground size-3" />
                      </button>
                    </Badge>
                  )
                })}
                {/* Avoid having the "Search" Icon */}

                <CommandPrimitive.Input
                  {...inputProps}
                  ref={inputRef}
                  value={inputValue}
                  disabled={disabled}
                  onValueChange={(value) => {
                    setInputValue(value)
                    inputProps?.onValueChange?.(value)
                  }}
                  onBlur={(event) => {
                    setOpen(false)
                    inputProps?.onBlur?.(event)
                  }}
                  onFocus={(event) => {
                    setOpen(true)
                    triggerSearchOnFocus && onSearch?.(debouncedSearchTerm)
                    inputProps?.onFocus?.(event)
                  }}
                  placeholder={
                    hidePlaceholderWhenSelected && selected.length !== 0
                      ? ''
                      : placeholder
                  }
                  className={cn(
                    'flex-1 bg-transparent outline-none placeholder:text-muted-foreground',
                    {
                      'w-full': hidePlaceholderWhenSelected,
                      'px-3 py-2': selected.length === 0,
                      'ml-1': selected.length !== 0
                    },
                    inputProps?.className
                  )}
                />
                {isLoading ? <SpinnerUi size="small" className="mx-4" /> : null}
              </div>
            </div>
            <CommandList className={'w-full'}>
              <>
                <div
                  className={isLoading ? 'h-auto opacity-100' : 'h-0 opacity-0'}
                >
                  {loadingIndicator}
                </div>
                {EmptyItem()}
                {CreatableItem()}
                {!selectFirstItem && (
                  <CommandItem value="-" className="hidden" />
                )}
                {Object.entries(selectables).map(([key, dropdowns]) => (
                  <CommandGroup
                    key={key}
                    heading={key}
                    className="h-full overflow-auto"
                  >
                    <>
                      {dropdowns.map((option) => {
                        return (
                          <CommandItem
                            key={option.value}
                            value={option.label}
                            disabled={option.disable}
                            onMouseDown={(e) => {
                              e.preventDefault()
                              e.stopPropagation()
                            }}
                            onSelect={() => {
                              if (selectMode === 'single') {
                                setInputValue('')
                                const newOptions = [option]
                                setSelected(newOptions)
                                onChange?.(newOptions)
                              } else {
                                if (selected.length >= maxSelected) {
                                  onMaxSelected?.(selected.length)
                                  return
                                }
                                setInputValue('')
                                const newOptions = [...selected, option]
                                setSelected(newOptions)
                                onChange?.(newOptions)
                              }
                            }}
                            className={cn(
                              'cursor-pointer',
                              option.disable &&
                                'cursor-default text-muted-foreground'
                            )}
                          >
                            <Check
                              className={cn(
                                'mr-2 h-4 w-4',
                                isUndefined(
                                  selected?.find((v) => {
                                    return v.value === option.value
                                  })
                                )
                                  ? 'opacity-0'
                                  : 'opacity-100'
                              )}
                            />
                            {option.label}
                          </CommandItem>
                        )
                      })}
                    </>
                  </CommandGroup>
                ))}
              </>
            </CommandList>
          </Command>
        </PopoverContent>
      </Popover>