diff --git a/.gitignore b/.gitignore index 91ce31a0..bf093351 100644 --- a/.gitignore +++ b/.gitignore @@ -239,4 +239,5 @@ documentation.json .envrc /public -/tests/axe-report.json \ No newline at end of file +/tests/axe-report.json +/tests/deprecated-imports-report.txt diff --git a/Makefile b/Makefile index 47956435..a69c536e 100644 --- a/Makefile +++ b/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 diff --git a/elm.json b/elm.json index 8cc9a2a6..b39b6daf 100644 --- a/elm.json +++ b/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", diff --git a/script/deprecated-imports.csv b/script/deprecated-imports.csv new file mode 100644 index 00000000..734eec0f --- /dev/null +++ b/script/deprecated-imports.csv @@ -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 diff --git a/script/deprecated-imports.py b/script/deprecated-imports.py new file mode 100755 index 00000000..100786f3 --- /dev/null +++ b/script/deprecated-imports.py @@ -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 = "(?PNri\.Ui\.\w+).V(?P\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[\w\.]*deprecated[\w\.]*)" + GENERIC_IMPORT_RE = "import\s+(?P[\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()) diff --git a/script/percy-tests.js b/script/percy-tests.js index 5e1417a2..877efe55 100644 --- a/script/percy-tests.js +++ b/script/percy-tests.js @@ -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()) + }) + }) }) diff --git a/src/Nri/Ui/Modal/V8.elm b/src/Nri/Ui/Modal/V8.elm index 4ce9c936..14b71c4c 100644 --- a/src/Nri/Ui/Modal/V8.elm +++ b/src/Nri/Ui/Modal/V8.elm @@ -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) diff --git a/src/Nri/Ui/Tooltip/V1.elm b/src/Nri/Ui/Tooltip/V1.elm index dcf747d7..ea678c04 100644 --- a/src/Nri/Ui/Tooltip/V1.elm +++ b/src/Nri/Ui/Tooltip/V1.elm @@ -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 -} 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 diff --git a/styleguide-app/Examples/Tooltip.elm b/styleguide-app/Examples/Tooltip.elm index 9a7ac071..bcd9e1f4 100644 --- a/styleguide-app/Examples/Tooltip.elm +++ b/styleguide-app/Examples/Tooltip.elm @@ -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" + ] + ] ] } diff --git a/tests/Spec/Nri/Ui/Tooltip/V1.elm b/tests/Spec/Nri/Ui/Tooltip/V1.elm index 07563392..9e38f1b9 100644 --- a/tests/Spec/Nri/Ui/Tooltip/V1.elm +++ b/tests/Spec/Nri/Ui/Tooltip/V1.elm @@ -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" ]