Merge branch 'master' into tessa/add-width-label-helpers

This commit is contained in:
Tessa 2019-12-05 12:33:36 -08:00 committed by GitHub
commit 4192cee498
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 555 additions and 61 deletions

3
.gitignore vendored
View File

@ -239,4 +239,5 @@ documentation.json
.envrc
/public
/tests/axe-report.json
/tests/axe-report.json
/tests/deprecated-imports-report.txt

View File

@ -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

View File

@ -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",

View 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
1 filename name version
2 src/Nri/Ui/Page/V3.elm Nri.Ui.Button 5
3 src/Nri/Ui/Page/V3.elm Nri.Ui.Text 2
4 src/Nri/Ui/Page/V2.elm Nri.Ui.Button 5
5 src/Nri/Ui/Page/V2.elm Nri.Ui.Text 2
6 src/Nri/Ui/PremiumCheckbox/V3.elm Nri.Ui.Checkbox 4
7 src/Nri/Ui/PremiumCheckbox/V2.elm Nri.Ui.Checkbox 3
8 src/Nri/Ui/PremiumCheckbox/V1.elm Nri.Ui.Checkbox 3
9 src/Nri/Ui/SlideModal/V2.elm Nri.Ui.Button 8
10 src/Nri/Ui/SlideModal/V2.elm Nri.Ui.Icon 3
11 src/Nri/Ui/SlideModal/V2.elm Nri.Ui.Text 2
12 src/Nri/Ui/SlideModal/V1.elm Nri.Ui.Button 8
13 src/Nri/Ui/SlideModal/V1.elm Nri.Ui.Icon 3
14 src/Nri/Ui/SlideModal/V1.elm Nri.Ui.Text 2
15 src/Nri/Ui/Alert/V3.elm Nri.Ui.Icon 3
16 src/Nri/Ui/Alert/V2.elm Nri.Ui.Icon 3
17 src/Nri/Ui/Alert/V4.elm Nri.Ui.Icon 3
18 src/Nri/Ui/SortableTable/V1.elm Nri.Ui.Table 4
19 src/Nri/Ui/Button/V3.elm Nri.Ui.Icon 3
20 src/Nri/Ui/Button/V5.elm Nri.Ui.Icon 3
21 src/Nri/Ui/Button/V4.elm Nri.Ui.Icon 3
22 src/Nri/Ui/Button/V6.elm Nri.Ui.Icon 4
23 src/Nri/Ui/Button/V7.elm Nri.Ui.Icon 4
24 src/Nri/Ui/SegmentedControl/V6.elm Nri.Ui.Icon 3
25 src/Nri/Ui/Modal/V3.elm Nri.Ui.Icon 3
26 src/Nri/Ui/Modal/V5.elm Nri.Ui.Modal 5
27 src/Nri/Ui/Modal/V4.elm Nri.Ui.Icon 3
28 src/Nri/Ui/Modal/V6.elm Nri.Ui.Modal 6
29 src/Nri/Ui/Modal/V7.elm Nri.Ui.Modal 7
30 src/Nri/Ui/ClickableText/V1.elm Nri.Ui.Icon 4

379
script/deprecated-imports.py Executable file
View 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())

View File

@ -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)

View File

@ -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

View File

@ -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"
]
]
]
}

View File

@ -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"
]