atls / hyperion

Highly ordered User Interface Kit
https://ui.atls.design
BSD 3-Clause "New" or "Revised" License
3 stars 0 forks source link

Переработка tooltip #388

Closed TFK70 closed 2 years ago

TFK70 commented 2 years ago

С чем связан запрос на фичу?

Текущая реализация тултипа блокирует фичи

Расскажите как вы это себе видите

Приложите примеры реализаций

В итоге хотелось бы увидеть API похожее на Card

TFK70 commented 2 years ago

Как я это вижу

Сам тултип я вижу на 2 версии лаага, так как она предоставляет более гибкую работу с контролсами, что очень хорошо решает нашу проблему с отслеживанием состояния тултипа. Также на втором лааге не встречалась проблема описанная тут. Если вкратце - проблема заключается в том, что closeOnOutsideClick не обновлялся в тот момент, пока тултип был открыт. На втором лааге такой проблемы нет.

Детали

Как решается проблема с closeOnOutsideClick

Как уже упоминалось, этот проп во второй версии лаага деприкейтед, но это потому что есть более гибкий способ управлять состоянием поповера. Пример:

  1. У нас есть хук обрабатывающий клик на триггер

use-click.hook.ts

import { useState } from 'react'

const useClick = () => {
  const [isClicked, setClicked] = useState(false)

  const close = () => setClicked(false)

  const clickProps = {
    onClick: () => setClicked(!isClicked)
  }

  return [isClicked, close, clickProps]
}

export { useClick }
  1. Инициализируем поповер через useLayer и используем состояние из useClick:
const [isClicked, close, clickProps] = useClick()
const { triggerProps, layerProps, renderLayer } = useLayer({ isOpen: isClicked })

return (
<>
    <div {...triggerProps}>Click me</div>
   {isClicked && renderLayer(
      <h1>Tooltip content</h1>
   )}
</>
)
  1. Добавляем outsideClick
const [isClicked, close, clickProps] = useClick()
const { triggerProps, layerProps, renderLayer } = useLayer({ isOpen: isClicked, onOutsideClick: close })

return (
<>
    <div {...triggerProps}>Click me</div>
   {isClicked && renderLayer(
      <h1>Tooltip content</h1>
   )}
</>
)

В чем разница между первой версией?

Все переменные состояние мы контролируем сами, что дает нам возможность полностью контролировать эвент onOutsideClick. Если сделать его опциональным код будет выглядеть так:

    onOutsideClick: closeOnOutsideClick ? close : () => {}

И он действительно будет обновляться даже когда поповер открыт.

Как решается проблема с отслеживанием состояния

Самый простой способ:

    <Tooltip
      trigger='click'
      container={(close) => (
        <>
        <h1>You can click inside tooltip</h1>
          <button onClick={close}>Or you may close it</button>
        </>
      )}
    >
      {(active, close) => (
        <>
        <h1>Now its {active ? 'active' : 'inactive'}</h1>
          {active && <button onClick={close}>Close it</button>}
        </>
      )}
    </Tooltip>

То есть мы обрабатываем дополнительный кейс когда чилдрен/контейнер - функция, и прокидываем туда как аргументы все необходимые контролсы

Похожее было реализовано тут

Миграция фичей

  1. anchor - во втором лааге все плейсменты пишутся в лоуеркейсе. Здесь есть 2 варианта: ломать обратную совместимость и писать их в лоуеркейсе (BOTTOM_RIGHT -> bottom_right) или преобразовывать эти значения в лоуеркейс внутри компонента с сохранением обратной совместимости
  2. trigger (hover, click, menu) - здесь никаких проблем нет, весь функционал остается (ниже приложу код)
  3. Все остальные пропы являющиеся дублированием API лаага будут заменены на новые отсюда

Как выглядит прототип (рабочий код)

import React from 'react'
import { useLayer } from 'react-laag'
import { useHover } from 'react-laag'

import { useClick } from './hooks'
import { useContextMenu } from './hooks'

type Trigger = 'click' | 'hover' | 'menu'

const Tooltip = ({ trigger = 'hover', children, container, closeOnOutsideClick }) => {
  const [isOver, hoverProps] = useHover()
  const [isClicked, closeClick, clickProps] = useClick()
  const [isContextMenu, closeContextMenu, contextMenuProps] = useContextMenu()

  const close = trigger === 'click' ? closeClick : closeContextMenu

  const getTrigger = () => {
    if (trigger === 'hover') return isOver
    if (trigger === 'click') return isClicked
    if (trigger === 'menu') return isContextMenu
  }

  const { triggerProps, layerProps, renderLayer } = useLayer({
    isOpen: getTrigger(),
    onOutsideClick: closeOnOutsideClick ? close : () => {}
  })

  const getTriggerProps = () => {
    if (trigger === 'hover') return { ...hoverProps, ...triggerProps }
    if (trigger === 'click') return { ...clickProps, ...triggerProps }
    if (trigger === 'menu') return { ...contextMenuProps, ...triggerProps }
    return triggerProps
  }

  const getChildrenControls = () => {
    if (trigger === 'hover') return [getTrigger(), () => {}]
    if (trigger === 'click' || trigger === 'menu') return [getTrigger(), close]
  }

  const getContainerControls = () => {
    if (trigger === 'hover') return []
    if (trigger === 'click' || trigger === 'menu') return [close]
  }

  return (
    <>
    <div {...getTriggerProps()}>
      {typeof children === 'function' ? children(...getChildrenControls()) : children}
    </div>
      {getTrigger() && renderLayer(<div {...layerProps}>{typeof container === 'function' ? container(...getContainerControls()) : container}</div>)}
    </>
  )
}

export { Tooltip }

Вот так выглядит его использование (сторисы):

import React, { useState }     from 'react'

import { Tooltip } from './tooltip.component'

export const Hover = () => {
  return (
    <Tooltip
      container={<div>Tooltip</div>}
    >
      Over me
    </Tooltip>
  )
}

export const Click = () => {
  return (
    <Tooltip
      trigger='click'
      container={<div>Tooltip</div>}
    >
      Click me
    </Tooltip>
  )
}

export const DynamicOutsideClick = () => {
  const [outsideClick, setOutsideClick] = useState(false)

  return (
    <>
    <Tooltip
      closeOnOutsideClick={outsideClick}
      trigger='click'
      container={<div>Tooltip</div>}
    >
      Click me
    </Tooltip>
      <button onClick={() => setOutsideClick(!outsideClick)}>Change outside click. Now {outsideClick ? 'true' : 'false'}</button>
    </>
  )
}

export const TrackState = () => {
  return (
    <Tooltip
      trigger='click'
      container={(close) => (
        <>
        <h1>You can click inside tooltip</h1>
          <button onClick={close}>Or you may close it</button>
        </>
      )}
    >
      {(active, close) => (
        <>
        <h1>Now its {active ? 'active' : 'inactive'}</h1>
          {active && <button onClick={close}>Close it</button>}
        </>
      )}
    </Tooltip>
  )
}

export const ContextMenu = () => {
  return (
    <Tooltip
      trigger='menu'
      container={<h1>Menu</h1>}
      closeOnOutsideClick={true}
    >
      <h1>Rght click</h1>
    </Tooltip>
  )
}

export default {
  title: 'Components/Tooltip',
}
TFK70 commented 2 years ago

Область применения Tooltip

Компонент используется для создания поповеров

Примеры мест в дизайне: профиль, баланс и уведомления

Самая большая проблема - это поповер + модалка. Так как модалка в доме находится за пределами поповера, то любое нажатие в области модалки будет считаться как outsideClick для поповера, и он как следствие будет закрываться когда нам не надо. Но и так как модалка маунтится внутри поповера - то она исчезает вместе с поповером как его чилдрен (еще и без анимации потому что AnimatePresence маунтится там же)