mirror of
https://github.com/NoRedInk/noredink-ui.git
synced 2024-12-29 16:44:41 +03:00
Merge branch 'master' into tessa/adds-missing-icons
This commit is contained in:
commit
722780c313
3
.gitignore
vendored
3
.gitignore
vendored
@ -239,4 +239,5 @@ documentation.json
|
||||
.envrc
|
||||
|
||||
/public
|
||||
/tests/axe-report.json
|
||||
/tests/axe-report.json
|
||||
/tests/deprecated-imports-report.txt
|
||||
|
9
Makefile
9
Makefile
@ -6,6 +6,7 @@ test: node_modules
|
||||
npx elm-verify-examples --run-tests
|
||||
make axe-report
|
||||
make percy-tests
|
||||
make deprecated-imports-report
|
||||
|
||||
tests/axe-report.json: public script/run-axe.sh script/axe-puppeteer.js
|
||||
script/run-axe.sh > $@
|
||||
@ -18,6 +19,14 @@ axe-report: tests/axe-report.json script/format-axe-report.sh script/axe-report.
|
||||
percy-tests:
|
||||
script/percy-tests.sh
|
||||
|
||||
tests/deprecated-imports-report.txt: $(shell find src -type f) script/deprecated-imports.py
|
||||
script/deprecated-imports.py report > $@
|
||||
|
||||
.PHONY: deprecated-imports-report
|
||||
deprecated-imports-report: tests/deprecated-imports-report.txt script/deprecated-imports.py
|
||||
@cat tests/deprecated-imports-report.txt
|
||||
@script/deprecated-imports.py check
|
||||
|
||||
.PHONY: checks
|
||||
checks:
|
||||
script/check-exposed.py
|
||||
|
2
elm.json
2
elm.json
@ -3,7 +3,7 @@
|
||||
"name": "NoRedInk/noredink-ui",
|
||||
"summary": "UI Widgets we use at NRI",
|
||||
"license": "BSD-3-Clause",
|
||||
"version": "7.14.0",
|
||||
"version": "7.14.2",
|
||||
"exposed-modules": [
|
||||
"Nri.Ui",
|
||||
"Nri.Ui.Accordion.V1",
|
||||
|
30
script/deprecated-imports.csv
Normal file
30
script/deprecated-imports.csv
Normal file
@ -0,0 +1,30 @@
|
||||
filename,name,version
|
||||
src/Nri/Ui/Page/V3.elm,Nri.Ui.Button,5
|
||||
src/Nri/Ui/Page/V3.elm,Nri.Ui.Text,2
|
||||
src/Nri/Ui/Page/V2.elm,Nri.Ui.Button,5
|
||||
src/Nri/Ui/Page/V2.elm,Nri.Ui.Text,2
|
||||
src/Nri/Ui/PremiumCheckbox/V3.elm,Nri.Ui.Checkbox,4
|
||||
src/Nri/Ui/PremiumCheckbox/V2.elm,Nri.Ui.Checkbox,3
|
||||
src/Nri/Ui/PremiumCheckbox/V1.elm,Nri.Ui.Checkbox,3
|
||||
src/Nri/Ui/SlideModal/V2.elm,Nri.Ui.Button,8
|
||||
src/Nri/Ui/SlideModal/V2.elm,Nri.Ui.Icon,3
|
||||
src/Nri/Ui/SlideModal/V2.elm,Nri.Ui.Text,2
|
||||
src/Nri/Ui/SlideModal/V1.elm,Nri.Ui.Button,8
|
||||
src/Nri/Ui/SlideModal/V1.elm,Nri.Ui.Icon,3
|
||||
src/Nri/Ui/SlideModal/V1.elm,Nri.Ui.Text,2
|
||||
src/Nri/Ui/Alert/V3.elm,Nri.Ui.Icon,3
|
||||
src/Nri/Ui/Alert/V2.elm,Nri.Ui.Icon,3
|
||||
src/Nri/Ui/Alert/V4.elm,Nri.Ui.Icon,3
|
||||
src/Nri/Ui/SortableTable/V1.elm,Nri.Ui.Table,4
|
||||
src/Nri/Ui/Button/V3.elm,Nri.Ui.Icon,3
|
||||
src/Nri/Ui/Button/V5.elm,Nri.Ui.Icon,3
|
||||
src/Nri/Ui/Button/V4.elm,Nri.Ui.Icon,3
|
||||
src/Nri/Ui/Button/V6.elm,Nri.Ui.Icon,4
|
||||
src/Nri/Ui/Button/V7.elm,Nri.Ui.Icon,4
|
||||
src/Nri/Ui/SegmentedControl/V6.elm,Nri.Ui.Icon,3
|
||||
src/Nri/Ui/Modal/V3.elm,Nri.Ui.Icon,3
|
||||
src/Nri/Ui/Modal/V5.elm,Nri.Ui.Modal,5
|
||||
src/Nri/Ui/Modal/V4.elm,Nri.Ui.Icon,3
|
||||
src/Nri/Ui/Modal/V6.elm,Nri.Ui.Modal,6
|
||||
src/Nri/Ui/Modal/V7.elm,Nri.Ui.Modal,7
|
||||
src/Nri/Ui/ClickableText/V1.elm,Nri.Ui.Icon,4
|
|
379
script/deprecated-imports.py
Executable file
379
script/deprecated-imports.py
Executable file
@ -0,0 +1,379 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Make sure we don't use deprecated versions of Nri.Ui modules. We do this by only
|
||||
allowing new imports for the highest version of every module. We keep an
|
||||
artifact with old imports which haven't been removed yet, and are temporarily
|
||||
allowed. See the subcommands of this script for managing this artifact!
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from collections import defaultdict
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import os.path
|
||||
import re
|
||||
import sys
|
||||
import textwrap
|
||||
|
||||
|
||||
class ElmJson:
|
||||
"""parse elm.json for things we will need to determine version usage"""
|
||||
|
||||
def __init__(self, path):
|
||||
with open(path, "r") as fh:
|
||||
self.data = json.load(fh)
|
||||
|
||||
self.elm_version = self.data["elm-version"]
|
||||
self.source_directories = ["src"]
|
||||
|
||||
|
||||
class NriUiModules:
|
||||
"""
|
||||
look in a given version of noredink-ui to get the versioned modules and the
|
||||
latest version for each.
|
||||
"""
|
||||
|
||||
MODULE_RE = "(?P<name>Nri\.Ui\.\w+).V(?P<version>\d+)"
|
||||
|
||||
def __init__(self):
|
||||
with open("elm.json", "r") as fh:
|
||||
self.module_list = json.load(fh)["exposed-modules"]
|
||||
|
||||
self.versions = defaultdict(list)
|
||||
for module in self.module_list:
|
||||
match = re.match(self.MODULE_RE, module)
|
||||
if match is None:
|
||||
continue
|
||||
|
||||
parts = match.groupdict()
|
||||
self.versions[parts["name"]].append(int(parts["version"]))
|
||||
self.versions[parts["name"]].sort()
|
||||
|
||||
def latest_version(self, name):
|
||||
if name in self.versions:
|
||||
return max(self.versions[name])
|
||||
|
||||
def is_latest_version(self, name, version):
|
||||
return self.latest_version(name) == version
|
||||
|
||||
|
||||
class Import:
|
||||
"""a single import."""
|
||||
def __init__(self, filename, name, version):
|
||||
self.filename = filename
|
||||
self.name = name
|
||||
self.version = int(version)
|
||||
|
||||
def to_dict(self):
|
||||
return {"filename": self.filename, "name": self.name, "version": self.version}
|
||||
|
||||
def __eq__(self, other):
|
||||
return (
|
||||
self.filename == other.filename
|
||||
and self.name == other.name
|
||||
and self.version == other.version
|
||||
)
|
||||
|
||||
def __hash__(self):
|
||||
# objects aren't hashable by default but we need to use this one in a
|
||||
# set. So we just make one big hash out of all the data we have!
|
||||
return hash(self.filename + self.name + str(self.version))
|
||||
|
||||
def __str__(self):
|
||||
return "{} imports {} version {}".format(self.filename, self.name, self.version)
|
||||
|
||||
|
||||
class Imports(list):
|
||||
"""
|
||||
get all the imports in the project according to specific rules.
|
||||
|
||||
this class is all about loading and storing this data, so we just subclass
|
||||
`list` to save some hassle on writing iteration etc.
|
||||
"""
|
||||
|
||||
NRI_UI_IMPORT_RE = "import\s+" + NriUiModules.MODULE_RE
|
||||
DEPRECATED_IMPORT_RE = "import\s+(?P<import>[\w\.]*deprecated[\w\.]*)"
|
||||
GENERIC_IMPORT_RE = "import\s+(?P<import>[\w\.]+)"
|
||||
|
||||
@classmethod
|
||||
def from_source_directories(cls, source_directories, extras):
|
||||
"""
|
||||
construct a list of project imports given an elm.json's source directories.
|
||||
"""
|
||||
results = cls()
|
||||
|
||||
for source_directory in source_directories:
|
||||
for (dirpath, _, filenames) in os.walk(source_directory):
|
||||
for filename in filenames:
|
||||
if os.path.splitext(filename)[1] != ".elm":
|
||||
continue
|
||||
|
||||
full_path = os.path.join(dirpath, filename)
|
||||
with open(full_path, "r") as fh:
|
||||
lines = [line.strip() for line in fh.readlines()]
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
|
||||
# add whatever imports we want to track here, continue
|
||||
# after each.
|
||||
|
||||
match = re.match(cls.NRI_UI_IMPORT_RE, line)
|
||||
if match is not None:
|
||||
parts = match.groupdict()
|
||||
|
||||
results.append(
|
||||
Import(
|
||||
filename=full_path,
|
||||
name=parts["name"],
|
||||
version=parts["version"],
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
match = re.match(
|
||||
cls.DEPRECATED_IMPORT_RE, line, flags=re.IGNORECASE
|
||||
)
|
||||
if match is not None:
|
||||
parts = match.groupdict()
|
||||
results.append(
|
||||
Import(
|
||||
filename=full_path,
|
||||
name=parts["import"],
|
||||
version=Import.DEPRECATED,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
match = re.match(cls.GENERIC_IMPORT_RE, line)
|
||||
if match is not None:
|
||||
parts = match.groupdict()
|
||||
if parts["import"] not in extras:
|
||||
continue
|
||||
|
||||
results.append(
|
||||
Import(
|
||||
filename=full_path,
|
||||
name=parts["import"],
|
||||
version=Import.DEPRECATED
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
return results
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, filename):
|
||||
try:
|
||||
with open(filename, "r") as fh:
|
||||
return cls([Import(**entry) for entry in csv.DictReader(fh)])
|
||||
except FileNotFoundError:
|
||||
return cls([])
|
||||
|
||||
def to_file(self, filename, filter=None):
|
||||
filter = filter or (lambda _: True)
|
||||
|
||||
out = (entry.to_dict() for entry in self if filter(entry))
|
||||
|
||||
with open(filename, "w") as fh:
|
||||
writer = csv.DictWriter(fh, ["filename", "name", "version"])
|
||||
writer.writeheader()
|
||||
writer.writerows(out)
|
||||
|
||||
|
||||
class Main:
|
||||
def __init__(self, args):
|
||||
self.args = args
|
||||
self.elm_json = ElmJson("elm.json")
|
||||
|
||||
self.deprecated = set(args.deprecate)
|
||||
|
||||
# cache-y things. These get set away from `None` the first time their
|
||||
# associated methods are called. This has the same effect as doing
|
||||
# `@foo ||= bar` in Ruby.
|
||||
self._current_imports = None
|
||||
self._previous_imports = None
|
||||
self._modules = None
|
||||
|
||||
def run(self):
|
||||
if self.args.command == "update":
|
||||
return self.update()
|
||||
elif self.args.command == "check":
|
||||
return self.check()
|
||||
elif self.args.command == "report":
|
||||
return self.report()
|
||||
else:
|
||||
print("unrecognized command. Make sure the cases in main match the parser.")
|
||||
return 1
|
||||
|
||||
def is_latest_version(self, name, version):
|
||||
return self.modules().is_latest_version(name, version) and name not in self.deprecated
|
||||
|
||||
def update(self):
|
||||
self.current_imports().to_file(
|
||||
self.args.imports_file,
|
||||
filter=lambda import_: not self.is_latest_version(import_.name, import_.version),
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
def check(self):
|
||||
new_imports = set(self.current_imports()) - set(self.previous_imports())
|
||||
new_deprecated_imports = set()
|
||||
|
||||
for entry in new_imports:
|
||||
if self.is_latest_version(entry.name, entry.version):
|
||||
continue
|
||||
|
||||
new_deprecated_imports.add(entry)
|
||||
|
||||
status = 0
|
||||
|
||||
if new_deprecated_imports:
|
||||
print("==== {} new deprecated imports".format(len(new_deprecated_imports)))
|
||||
print(
|
||||
"\n".join(
|
||||
"{} (latest version: {})".format(
|
||||
entry,
|
||||
self.modules().latest_version(entry.name)
|
||||
or "completely deprecated.",
|
||||
)
|
||||
for entry in new_deprecated_imports
|
||||
)
|
||||
)
|
||||
print()
|
||||
print(
|
||||
textwrap.fill(
|
||||
textwrap.dedent(
|
||||
"""
|
||||
If you meant to import a deprecated version, please run
|
||||
`{}` and commit the changes to mark these deprecated
|
||||
imports as acceptable. Otherwise, please upgrade to the
|
||||
latest version (listed in each error above.)
|
||||
"""
|
||||
),
|
||||
width=80,
|
||||
).format(self.args.check_message_fix_command)
|
||||
)
|
||||
status = 1
|
||||
|
||||
newly_removed = set(self.previous_imports()) - set(self.current_imports())
|
||||
if newly_removed:
|
||||
print()
|
||||
print("==== {} newly removed imports".format(len(newly_removed)))
|
||||
print("\n".join(str(entry) for entry in newly_removed))
|
||||
print()
|
||||
print(
|
||||
"good job! Please run `{}` and commit the output to make sure these don't come back".format(
|
||||
self.args.check_message_fix_command
|
||||
)
|
||||
)
|
||||
status = 1
|
||||
|
||||
return status
|
||||
|
||||
def report(self):
|
||||
allowed_counts = {True: 0, False: 0}
|
||||
counts = defaultdict(lambda: {True: 0, False: 0})
|
||||
|
||||
for entry in self.current_imports():
|
||||
allowed = self.is_latest_version(entry.name, entry.version)
|
||||
|
||||
allowed_counts[allowed] += 1
|
||||
counts[entry.name][allowed] += 1
|
||||
|
||||
try:
|
||||
widest = max(len(name) for name in counts)
|
||||
except ValueError: # empty sequence
|
||||
widest = 1
|
||||
|
||||
lines = []
|
||||
for name, alloweds in counts.items():
|
||||
total = alloweds[False] + alloweds[True]
|
||||
percent = alloweds[True] / total
|
||||
|
||||
out = "{} {: 4} of {: 4} ({:> 8.2%}) imports use the latest version".format(
|
||||
name.ljust(widest), alloweds[True], total, percent
|
||||
)
|
||||
|
||||
lines.append((percent, out))
|
||||
|
||||
lines.sort()
|
||||
print("\n".join(line for (_, line) in lines))
|
||||
print()
|
||||
|
||||
total = allowed_counts[False] + allowed_counts[True]
|
||||
percent = allowed_counts[True] / (total or 1)
|
||||
print(
|
||||
"in total, {} of {} ({:> 8.2%}) imports use the latest version".format(
|
||||
allowed_counts[True], total, percent
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
def previous_imports(self):
|
||||
if self._previous_imports is None:
|
||||
self._previous_imports = Imports.from_file(self.args.imports_file)
|
||||
|
||||
return self._previous_imports
|
||||
|
||||
def current_imports(self):
|
||||
if self._current_imports is None:
|
||||
self._current_imports = Imports.from_source_directories(
|
||||
[
|
||||
os.path.join(os.path.dirname("elm.json"), directory)
|
||||
for directory in self.elm_json.source_directories
|
||||
],
|
||||
self.deprecated
|
||||
)
|
||||
|
||||
return self._current_imports
|
||||
|
||||
def modules(self):
|
||||
if self._modules is None:
|
||||
self._modules = NriUiModules()
|
||||
|
||||
return self._modules
|
||||
|
||||
|
||||
def parser():
|
||||
out = argparse.ArgumentParser(
|
||||
description=__doc__, formatter_class=argparse.ArgumentDefaultsHelpFormatter
|
||||
)
|
||||
|
||||
out.add_argument(
|
||||
"--check-message-fix-command",
|
||||
help="update command to show in check error messages",
|
||||
default=" ".join(sys.argv).replace("check", "update").replace("report", "update"),
|
||||
)
|
||||
out.add_argument(
|
||||
"--imports-file",
|
||||
help="where do we store acceptable deprecated imports?",
|
||||
default=os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)),
|
||||
os.path.basename(__file__).replace(".py", ".csv"),
|
||||
),
|
||||
)
|
||||
out.add_argument(
|
||||
'--deprecate',
|
||||
help="explicitly deprecate a module by name",
|
||||
action="append",
|
||||
default=[],
|
||||
)
|
||||
|
||||
sub = out.add_subparsers()
|
||||
sub.required = True
|
||||
sub.dest = "command"
|
||||
|
||||
sub.add_parser("update", help="update the acceptable import file")
|
||||
sub.add_parser(
|
||||
"check", help="check that we do not have any new instances of old modules"
|
||||
)
|
||||
sub.add_parser("report", help="print an import report")
|
||||
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(Main(parser().parse_args()).run())
|
@ -1,59 +1,39 @@
|
||||
const PercyScript = require('@percy/script')
|
||||
|
||||
PercyScript.run(async (page, percySnapshot) => {
|
||||
await page.goto('http://localhost:8000/#category/Animations')
|
||||
await page.waitFor('#animations')
|
||||
await percySnapshot('Animations')
|
||||
|
||||
await page.goto('http://localhost:8000/#category/Buttons')
|
||||
await page.waitFor('#buttons-and-links')
|
||||
await percySnapshot('Buttons')
|
||||
|
||||
await page.goto('http://localhost:8000/#category/Colors')
|
||||
await page.waitFor('#colors')
|
||||
await percySnapshot('Colors')
|
||||
|
||||
await page.goto('http://localhost:8000/#category/Icons')
|
||||
await page.waitFor('#icons')
|
||||
await percySnapshot('Icons')
|
||||
|
||||
await page.goto('http://localhost:8000/#category/Inputs')
|
||||
await page.waitFor('#inputs')
|
||||
await percySnapshot('Inputs')
|
||||
|
||||
await page.goto('http://localhost:8000/#category/Layout')
|
||||
await page.waitFor('#layout')
|
||||
await percySnapshot('Layout')
|
||||
|
||||
await page.goto('http://localhost:8000/#category/Modals')
|
||||
await page.waitFor('#modals')
|
||||
await percySnapshot('Modals')
|
||||
await page.click('#launch-info-modal')
|
||||
await page.waitFor('[role="dialog"]')
|
||||
await percySnapshot('Full Info Modal')
|
||||
await page.click('[aria-label="Close modal"]')
|
||||
await page.click('#launch-warning-modal')
|
||||
await page.waitFor('[role="dialog"]')
|
||||
await percySnapshot('Full Warning Modal')
|
||||
await page.click('[aria-label="Close modal"]')
|
||||
|
||||
await page.goto('http://localhost:8000/#category/Pages')
|
||||
await page.waitFor('#error-pages')
|
||||
await percySnapshot('Pages')
|
||||
|
||||
await page.goto('http://localhost:8000/#category/Tables')
|
||||
await page.waitFor('#tables')
|
||||
await percySnapshot('Tables')
|
||||
|
||||
await page.goto('http://localhost:8000/#category/Text')
|
||||
await page.waitFor('#text-and-fonts')
|
||||
await percySnapshot('Text')
|
||||
|
||||
await page.goto('http://localhost:8000/#category/Widgets')
|
||||
await page.waitFor('#widgets')
|
||||
await percySnapshot('Widgets')
|
||||
|
||||
await page.goto('http://localhost:8000/#category/Messaging')
|
||||
await page.waitFor('#alerts-and-messages')
|
||||
await percySnapshot('Messaging')
|
||||
const defaultProcessing = async (name, id, location) => {
|
||||
await page.goto(location)
|
||||
await page.waitFor(`#${id}`)
|
||||
await percySnapshot(name)
|
||||
console.log(`Snapshot complete for ${name}`)
|
||||
}
|
||||
const specialProcessing = {
|
||||
'All': async (_) => {
|
||||
},
|
||||
'Modals': async (name, id, location) => {
|
||||
await defaultProcessing(name, id, location)
|
||||
await page.click('#launch-info-modal')
|
||||
await page.waitFor('[role="dialog"]')
|
||||
await percySnapshot('Full Info Modal')
|
||||
await page.click('[aria-label="Close modal"]')
|
||||
await page.click('#launch-warning-modal')
|
||||
await page.waitFor('[role="dialog"]')
|
||||
await percySnapshot('Full Warning Modal')
|
||||
await page.click('[aria-label="Close modal"]')
|
||||
}
|
||||
}
|
||||
await page.goto('http://localhost:8000')
|
||||
await page.waitFor('#categories').then(category => {
|
||||
return category.$$('a').then(links => {
|
||||
return links.reduce((acc, link) => {
|
||||
return acc.then(_ => {
|
||||
return link.evaluate(node => [node.innerText, node.innerText.toLowerCase().replace(/ /g, "-"), node.href]).then(([name, id, location]) => {
|
||||
let handler = specialProcessing[name] || defaultProcessing
|
||||
return handler(name, id, location)
|
||||
})
|
||||
}
|
||||
)
|
||||
}, Promise.resolve())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -344,6 +344,7 @@ viewInnerContent children visibleTitle visibleFooter =
|
||||
[ div
|
||||
[ css
|
||||
[ Css.overflowY Css.auto
|
||||
, Css.overflowX Css.hidden
|
||||
, Css.minHeight (Css.px 150)
|
||||
, Css.maxHeight
|
||||
(Css.calc (Css.vh 100)
|
||||
|
@ -26,6 +26,14 @@ Example usage:
|
||||
}
|
||||
|
||||
|
||||
## Suggested Improvements for V2
|
||||
|
||||
- The toggle tip does not currently manage focus correctly for keyboard users - if a
|
||||
user tries to click on a link in the toggle tip, the tip will disappear as focus moves
|
||||
to the next item in the page. This should be improved in the next release.
|
||||
- Currently, only toggle tip supports links on hover - generalize this to all tooltips
|
||||
|
||||
|
||||
## Tooltip Construction
|
||||
|
||||
@docs Tooltip, tooltip
|
||||
@ -223,7 +231,7 @@ auxillaryDescription =
|
||||
|
||||
{-| Supplementary information triggered by a "?" icon
|
||||
|
||||
A toggle tip is always triggered by a click.
|
||||
A toggle tip is always triggered by a hover (or focus, for keyboard users)
|
||||
|
||||
-}
|
||||
toggleTip :
|
||||
@ -235,37 +243,73 @@ toggleTip :
|
||||
-> Tooltip msg
|
||||
-> Html msg
|
||||
toggleTip { isOpen, onTrigger, extraButtonAttrs, label } tooltip_ =
|
||||
let
|
||||
contentSize =
|
||||
20
|
||||
in
|
||||
Nri.Ui.styled Html.div
|
||||
"Nri-Ui-Tooltip-V1-ToggleTip"
|
||||
tooltipContainerStyles
|
||||
(tooltipContainerStyles
|
||||
++ [ -- Take up enough room within the document flow
|
||||
Css.width (Css.px contentSize)
|
||||
, Css.height (Css.px contentSize)
|
||||
, Css.margin (Css.px 5)
|
||||
]
|
||||
)
|
||||
[]
|
||||
[ Html.button
|
||||
([ Widget.label label
|
||||
, css
|
||||
([ Css.width (Css.px 20)
|
||||
, Css.height (Css.px 20)
|
||||
, Css.color Colors.azure
|
||||
]
|
||||
++ buttonStyleOverrides
|
||||
)
|
||||
, EventExtras.onClickStopPropagation (onTrigger True)
|
||||
, Events.onBlur (onTrigger False)
|
||||
, css buttonStyleOverrides
|
||||
]
|
||||
++ eventsForTrigger OnHover onTrigger
|
||||
++ extraButtonAttrs
|
||||
)
|
||||
[ iconHelp ]
|
||||
, viewIf (\_ -> viewCloseTooltipOverlay (onTrigger False)) isOpen
|
||||
, Html.span
|
||||
[ -- This adds aria-live polite & also aria-live atomic, so our screen readers are alerted when content appears
|
||||
Role.status
|
||||
]
|
||||
[ -- Popout is rendered after the overlay, to allow client code to give it
|
||||
-- priority when clicking by setting its position
|
||||
viewIf (\_ -> viewTooltip Nothing tooltip_) isOpen
|
||||
[ hoverBridge contentSize
|
||||
[ Html.div
|
||||
[ css
|
||||
[ Css.position Css.relative
|
||||
, Css.width (Css.px contentSize)
|
||||
, Css.height (Css.px contentSize)
|
||||
, Css.color Colors.azure
|
||||
]
|
||||
]
|
||||
[ iconHelp
|
||||
, Html.span
|
||||
[ -- This adds aria-live polite & also aria-live atomic, so our screen readers are alerted when content appears
|
||||
Role.status
|
||||
]
|
||||
[ viewIf (\_ -> viewTooltip Nothing OnHover tooltip_) isOpen ]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
{-| Provides a "bridge" for the cursor to move from trigger content to tooltip, so the user can click on links, etc.
|
||||
|
||||
Works by being larger than the trigger content & overlaying it, but is removed from the flow of the page (position: absolute), so that it looks ok visually.
|
||||
|
||||
-}
|
||||
hoverBridge : Float -> List (Html msg) -> Html msg
|
||||
hoverBridge contentSize =
|
||||
let
|
||||
padding =
|
||||
-- enough to cover the empty gap between tooltip and trigger content
|
||||
10
|
||||
in
|
||||
Nri.Ui.styled Html.div
|
||||
"tooltip-hover-bridge"
|
||||
[ Css.boxSizing Css.borderBox
|
||||
, Css.padding (Css.px padding)
|
||||
, Css.width (Css.px <| contentSize + padding * 2)
|
||||
, Css.height (Css.px <| contentSize + padding * 2)
|
||||
, Css.position Css.absolute
|
||||
, Css.top (Css.px <| negate padding)
|
||||
, Css.left (Css.px <| negate padding)
|
||||
]
|
||||
[]
|
||||
|
||||
|
||||
{-| Made with <https://levelteams.com/svg-to-elm>
|
||||
-}
|
||||
iconHelp : Svg msg
|
||||
@ -333,7 +377,7 @@ viewTooltip_ purpose { trigger, triggerHtml, onTrigger, isOpen, id, extraButtonA
|
||||
|
||||
-- Popout is rendered after the overlay, to allow client code to give it
|
||||
-- priority when clicking by setting its position
|
||||
, viewIf (\_ -> viewTooltip (Just id) tooltip_) isOpen
|
||||
, viewIf (\_ -> viewTooltip (Just id) trigger tooltip_) isOpen
|
||||
]
|
||||
|
||||
|
||||
@ -349,9 +393,9 @@ viewIf viewFn condition =
|
||||
Html.text ""
|
||||
|
||||
|
||||
viewTooltip : Maybe String -> Tooltip msg -> Html msg
|
||||
viewTooltip maybeTooltipId (Tooltip config) =
|
||||
Html.div (containerPositioningForArrowPosition config.position)
|
||||
viewTooltip : Maybe String -> Trigger -> Tooltip msg -> Html msg
|
||||
viewTooltip maybeTooltipId trigger (Tooltip config) =
|
||||
Html.div [ css (containerPositioningForArrowPosition config.position) ]
|
||||
[ Html.div
|
||||
([ css
|
||||
([ Css.borderRadius (Css.px 8)
|
||||
@ -426,33 +470,32 @@ tooltipColor =
|
||||
|
||||
{-| This returns an absolute positioning style attribute for the popout container for a given arrow position.
|
||||
-}
|
||||
containerPositioningForArrowPosition : Position -> List (Attribute msg)
|
||||
containerPositioningForArrowPosition : Position -> List Style
|
||||
containerPositioningForArrowPosition arrowPosition =
|
||||
List.map (\( k, v ) -> Attributes.style k v) <|
|
||||
case arrowPosition of
|
||||
OnTop ->
|
||||
[ ( "left", "50%" )
|
||||
, ( "top", "calc(-" ++ String.fromFloat arrowSize ++ "px - 2px)" )
|
||||
, ( "position", "absolute" )
|
||||
]
|
||||
case arrowPosition of
|
||||
OnTop ->
|
||||
[ Css.left (Css.pct 50)
|
||||
, Css.top (Css.calc (Css.px (negate arrowSize)) Css.minus (Css.px 2))
|
||||
, Css.position Css.absolute
|
||||
]
|
||||
|
||||
OnBottom ->
|
||||
[ ( "left", "50%" )
|
||||
, ( "bottom", "calc(-" ++ String.fromFloat arrowSize ++ "px - 2px)" )
|
||||
, ( "position", "absolute" )
|
||||
]
|
||||
OnBottom ->
|
||||
[ Css.left (Css.pct 50)
|
||||
, Css.bottom (Css.calc (Css.px (negate arrowSize)) Css.minus (Css.px 2))
|
||||
, Css.position Css.absolute
|
||||
]
|
||||
|
||||
OnLeft ->
|
||||
[ ( "top", "50%" )
|
||||
, ( "left", "calc(-" ++ String.fromFloat arrowSize ++ "px - 2px)" )
|
||||
, ( "position", "absolute" )
|
||||
]
|
||||
OnLeft ->
|
||||
[ Css.top (Css.pct 50)
|
||||
, Css.left (Css.calc (Css.px (negate arrowSize)) Css.minus (Css.px 2))
|
||||
, Css.position Css.absolute
|
||||
]
|
||||
|
||||
OnRight ->
|
||||
[ ( "top", "50%" )
|
||||
, ( "right", "calc(-" ++ String.fromFloat arrowSize ++ "px - 2px)" )
|
||||
, ( "position", "absolute" )
|
||||
]
|
||||
OnRight ->
|
||||
[ Css.top (Css.pct 50)
|
||||
, Css.right (Css.calc (Css.px (negate arrowSize)) Css.minus (Css.px 2))
|
||||
, Css.position Css.absolute
|
||||
]
|
||||
|
||||
|
||||
pointerBox : Position -> Attribute msg
|
||||
|
@ -8,10 +8,10 @@ module Examples.Tooltip exposing (example, init, update, State, Msg)
|
||||
|
||||
import Accessibility.Styled as Html
|
||||
import Css
|
||||
import Html.Styled.Attributes exposing (css)
|
||||
import Html.Styled.Attributes exposing (css, href)
|
||||
import ModuleExample as ModuleExample exposing (Category(..), ModuleExample)
|
||||
import Nri.Ui.Heading.V2 as Heading
|
||||
import Nri.Ui.Text.V3 as Text
|
||||
import Nri.Ui.Text.V4 as Text
|
||||
import Nri.Ui.Tooltip.V1 as Tooltip
|
||||
|
||||
|
||||
@ -19,7 +19,10 @@ type TooltipType
|
||||
= PrimaryLabelOnClick
|
||||
| PrimaryLabelOnHover
|
||||
| AuxillaryDescription
|
||||
| ToggleTip
|
||||
| ToggleTipTop
|
||||
| ToggleTipRight
|
||||
| ToggleTipBottom
|
||||
| ToggleTipLeft
|
||||
|
||||
|
||||
type alias State =
|
||||
@ -97,12 +100,40 @@ example msg model =
|
||||
, Html.br [ css [ Css.marginBottom (Css.px 20) ] ]
|
||||
, Heading.h3 [] [ Html.text "toggleTip" ]
|
||||
, Text.smallBody [ Html.text "A Toggle Tip is triggered by the \"?\" icon and provides supplemental information for the page." ]
|
||||
, Tooltip.tooltip [ Html.text "Tooltip" ]
|
||||
|> Tooltip.toggleTip
|
||||
{ onTrigger = ToggleTooltip ToggleTip >> msg
|
||||
, isOpen = model.openTooltip == Just ToggleTip
|
||||
, label = "More info"
|
||||
, extraButtonAttrs = []
|
||||
}
|
||||
, Html.div [ css [ Css.displayFlex, Css.alignItems Css.center ] ]
|
||||
[ Tooltip.tooltip
|
||||
[ Html.text "Tooltip On Top! "
|
||||
, Html.a
|
||||
[ href "/" ]
|
||||
[ Html.text "Links work!" ]
|
||||
]
|
||||
|> Tooltip.toggleTip
|
||||
{ onTrigger = ToggleTooltip ToggleTipTop >> msg
|
||||
, isOpen = model.openTooltip == Just ToggleTipTop
|
||||
, label = "More info"
|
||||
, extraButtonAttrs = []
|
||||
}
|
||||
, Text.mediumBody
|
||||
[ Html.text "This toggletip will open on top"
|
||||
]
|
||||
]
|
||||
, Html.div [ css [ Css.displayFlex, Css.alignItems Css.center ] ]
|
||||
[ Tooltip.tooltip
|
||||
[ Html.text "Tooltip On Left! "
|
||||
, Html.a
|
||||
[ href "/" ]
|
||||
[ Html.text "Links work!" ]
|
||||
]
|
||||
|> Tooltip.withPosition Tooltip.OnLeft
|
||||
|> Tooltip.toggleTip
|
||||
{ onTrigger = ToggleTooltip ToggleTipLeft >> msg
|
||||
, isOpen = model.openTooltip == Just ToggleTipLeft
|
||||
, label = "More info"
|
||||
, extraButtonAttrs = []
|
||||
}
|
||||
, Text.mediumBody
|
||||
[ Html.text "This toggletip will open on the left"
|
||||
]
|
||||
]
|
||||
]
|
||||
}
|
||||
|
@ -37,7 +37,7 @@ spec : Test
|
||||
spec =
|
||||
describe "Nri.Ui.Tooltip.V1"
|
||||
[ describe "toggleTip"
|
||||
[ test "Toggletip is available on click and hides on blur" <|
|
||||
[ test "Toggletip is available on hover and hides on blur" <|
|
||||
\() ->
|
||||
ProgramTest.createSandbox
|
||||
{ init = init
|
||||
@ -52,7 +52,7 @@ spec =
|
||||
(Widget.label "More info")
|
||||
]
|
||||
)
|
||||
Event.click
|
||||
Event.mouseEnter
|
||||
|> ProgramTest.ensureViewHas
|
||||
[ Selector.text "Toggly"
|
||||
]
|
||||
|
Loading…
Reference in New Issue
Block a user