|
@@ -1,9 +1,11 @@
|
|
|
'use client'
|
|
'use client'
|
|
|
|
|
|
|
|
import { useEffect } from 'react'
|
|
import { useEffect } from 'react'
|
|
|
|
|
+import Link from 'next/link'
|
|
|
import { useTranslations } from 'next-intl'
|
|
import { useTranslations } from 'next-intl'
|
|
|
import { Popover, PopoverAnchor, PopoverArrow, PopoverContent } from '@/components/ui/popover'
|
|
import { Popover, PopoverAnchor, PopoverArrow, PopoverContent } from '@/components/ui/popover'
|
|
|
import { Button } from '@/components/ui/button'
|
|
import { Button } from '@/components/ui/button'
|
|
|
|
|
+import { cn } from '@/lib/utils'
|
|
|
import { useFeatureHint } from './feature-hint-provider'
|
|
import { useFeatureHint } from './feature-hint-provider'
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
@@ -20,7 +22,8 @@ import { useFeatureHint } from './feature-hint-provider'
|
|
|
*
|
|
*
|
|
|
* The anchor is an anchor, not a trigger: the link underneath stays clickable,
|
|
* The anchor is an anchor, not a trigger: the link underneath stays clickable,
|
|
|
* and clicking it counts as having found the thing, so the hint closes.
|
|
* and clicking it counts as having found the thing, so the hint closes.
|
|
|
- * Anything else outside closes it too.
|
|
|
|
|
|
|
+ * Anything else outside closes it too, unless the caller asks for an
|
|
|
|
|
+ * acknowledgement.
|
|
|
*
|
|
*
|
|
|
* Non-modal on purpose. It never traps focus and never steals it, because
|
|
* Non-modal on purpose. It never traps focus and never steals it, because
|
|
|
* stealing focus on page load throws a keyboard user out of whatever they were
|
|
* stealing focus on page load throws a keyboard user out of whatever they were
|
|
@@ -32,6 +35,9 @@ export function FeatureHint({
|
|
|
title,
|
|
title,
|
|
|
body,
|
|
body,
|
|
|
side = 'right',
|
|
side = 'right',
|
|
|
|
|
+ href,
|
|
|
|
|
+ cta,
|
|
|
|
|
+ variant = 'hint',
|
|
|
children,
|
|
children,
|
|
|
}: {
|
|
}: {
|
|
|
/**
|
|
/**
|
|
@@ -44,6 +50,33 @@ export function FeatureHint({
|
|
|
title: string
|
|
title: string
|
|
|
body: string
|
|
body: string
|
|
|
side?: 'top' | 'right' | 'bottom' | 'left'
|
|
side?: 'top' | 'right' | 'bottom' | 'left'
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Where the feature lives. A hint about a toggle somebody just flipped needs
|
|
|
|
|
+ * no link, because the anchor underneath already is the way there. An
|
|
|
|
|
+ * announcement about something further in does: the anchored link is only
|
|
|
|
|
+ * the neighbourhood, and leaving somebody to hunt for the rest of the route
|
|
|
|
|
+ * is how an announcement ends up ignored.
|
|
|
|
|
+ */
|
|
|
|
|
+ href?: string
|
|
|
|
|
+ /** The wording on that link. Required whenever `href` is given. */
|
|
|
|
|
+ cta?: string
|
|
|
|
|
+ /**
|
|
|
|
|
+ * How much of an event this is, which decides both how loud the card looks
|
|
|
|
|
+ * and what it takes to close it.
|
|
|
|
|
+ *
|
|
|
|
|
+ * A `hint` follows something the workshop just did, so it is a quiet note in
|
|
|
|
|
+ * the usual popover colours and a click anywhere is fair evidence they are
|
|
|
|
|
+ * done reading.
|
|
|
|
|
+ *
|
|
|
|
|
+ * An `announcement` is the opposite on both counts. Nobody asked for it, so
|
|
|
|
|
+ * it is painted in the accent colour to be worth the interruption rather
|
|
|
|
|
+ * than sitting white on a white page. And it is the one time the workshop is
|
|
|
|
|
+ * told, dismissed for everybody at once, so it waits for a button: spending
|
|
|
|
|
+ * that on a stray click at the far side of the screen is how a colleague
|
|
|
|
|
+ * ends up never hearing about the feature at all. Escape still closes it,
|
|
|
|
|
+ * being a deliberate keypress rather than a mis-aimed click.
|
|
|
|
|
+ */
|
|
|
|
|
+ variant?: 'hint' | 'announcement'
|
|
|
/**
|
|
/**
|
|
|
* What the card points at. Stays fully interactive. Given the open state so
|
|
* What the card points at. Stays fully interactive. Given the open state so
|
|
|
* it can highlight itself while the card is up.
|
|
* it can highlight itself while the card is up.
|
|
@@ -52,6 +85,7 @@ export function FeatureHint({
|
|
|
}) {
|
|
}) {
|
|
|
const t = useTranslations('common')
|
|
const t = useTranslations('common')
|
|
|
const { open, dismiss } = useFeatureHint(id, eligible)
|
|
const { open, dismiss } = useFeatureHint(id, eligible)
|
|
|
|
|
+ const loud = variant === 'announcement'
|
|
|
|
|
|
|
|
// Escape closes it wherever focus happens to be. Focus is never moved into
|
|
// Escape closes it wherever focus happens to be. Focus is never moved into
|
|
|
// the card, and Radix only sees the key when it is.
|
|
// the card, and Radix only sees the key when it is.
|
|
@@ -77,17 +111,39 @@ export function FeatureHint({
|
|
|
// Focus stays where the person put it.
|
|
// Focus stays where the person put it.
|
|
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
|
|
onCloseAutoFocus={(event) => event.preventDefault()}
|
|
onCloseAutoFocus={(event) => event.preventDefault()}
|
|
|
- // Clicking the thing it points at counts as finding it.
|
|
|
|
|
- onPointerDownOutside={() => dismiss()}
|
|
|
|
|
|
|
+ // Clicking the thing it points at counts as finding it, unless this
|
|
|
|
|
+ // one is waiting to be acknowledged. `open` is controlled, so leaving
|
|
|
|
|
+ // the click alone simply leaves the card up.
|
|
|
|
|
+ onPointerDownOutside={() => {
|
|
|
|
|
+ if (!loud) dismiss()
|
|
|
|
|
+ }}
|
|
|
onEscapeKeyDown={() => dismiss()}
|
|
onEscapeKeyDown={() => dismiss()}
|
|
|
aria-labelledby={`${id}-hint-title`}
|
|
aria-labelledby={`${id}-hint-title`}
|
|
|
aria-describedby={`${id}-hint-body`}
|
|
aria-describedby={`${id}-hint-body`}
|
|
|
- className="w-auto max-w-xs border-primary/30 p-0 shadow-lg"
|
|
|
|
|
|
|
+ className={cn(
|
|
|
|
|
+ 'w-auto max-w-xs p-0',
|
|
|
|
|
+ loud
|
|
|
|
|
+ ? // The surface a selected sidebar link wears, so the card reads as
|
|
|
|
|
+ // part of the nav it is pointing into. A solid token rather than
|
|
|
|
|
+ // primary at half alpha, which would let the page read through
|
|
|
|
|
+ // the card's own text.
|
|
|
|
|
+ 'border-primary/40 bg-sidebar-accent text-sidebar-accent-foreground shadow-xl'
|
|
|
|
|
+ : 'border-primary/30 shadow-lg'
|
|
|
|
|
+ )}
|
|
|
>
|
|
>
|
|
|
- <PopoverArrow className="fill-popover stroke-primary/30" width={12} height={6} />
|
|
|
|
|
|
|
+ <PopoverArrow
|
|
|
|
|
+ className={
|
|
|
|
|
+ loud ? 'fill-sidebar-accent stroke-primary/40' : 'fill-popover stroke-primary/30'
|
|
|
|
|
+ }
|
|
|
|
|
+ width={12}
|
|
|
|
|
+ height={6}
|
|
|
|
|
+ />
|
|
|
{/* Announced without moving focus, so a screen reader user hears it
|
|
{/* Announced without moving focus, so a screen reader user hears it
|
|
|
without being pulled out of what they were doing. */}
|
|
without being pulled out of what they were doing. */}
|
|
|
- <div aria-live="polite" className="flex items-center gap-3 py-2 pr-2 pl-3">
|
|
|
|
|
|
|
+ <div
|
|
|
|
|
+ aria-live="polite"
|
|
|
|
|
+ className={href ? 'flex flex-col gap-2 p-3' : 'flex items-center gap-3 py-2 pr-2 pl-3'}
|
|
|
|
|
+ >
|
|
|
<div className="min-w-0">
|
|
<div className="min-w-0">
|
|
|
<p id={`${id}-hint-title`} className="text-sm font-semibold">
|
|
<p id={`${id}-hint-title`} className="text-sm font-semibold">
|
|
|
{title}
|
|
{title}
|
|
@@ -96,13 +152,33 @@ export function FeatureHint({
|
|
|
{body}
|
|
{body}
|
|
|
</p>
|
|
</p>
|
|
|
</div>
|
|
</div>
|
|
|
- <Button
|
|
|
|
|
- size="sm"
|
|
|
|
|
- className="h-7 shrink-0 self-center px-2.5 text-xs"
|
|
|
|
|
- onClick={() => dismiss()}
|
|
|
|
|
- >
|
|
|
|
|
- {t('buttons.gotIt')}
|
|
|
|
|
- </Button>
|
|
|
|
|
|
|
+ {href && cta ? (
|
|
|
|
|
+ // Going there settles it: somebody who followed the link has been
|
|
|
|
|
+ // told, so the workshop is not asked to acknowledge it as well.
|
|
|
|
|
+ <div className="flex items-center justify-end gap-1.5">
|
|
|
|
|
+ <Button
|
|
|
|
|
+ size="sm"
|
|
|
|
|
+ variant="ghost"
|
|
|
|
|
+ className="h-7 px-2.5 text-xs"
|
|
|
|
|
+ onClick={() => dismiss()}
|
|
|
|
|
+ >
|
|
|
|
|
+ {t('buttons.gotIt')}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button asChild size="sm" className="h-7 px-2.5 text-xs">
|
|
|
|
|
+ <Link href={href} onClick={() => dismiss()}>
|
|
|
|
|
+ {cta}
|
|
|
|
|
+ </Link>
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <Button
|
|
|
|
|
+ size="sm"
|
|
|
|
|
+ className="h-7 shrink-0 self-center px-2.5 text-xs"
|
|
|
|
|
+ onClick={() => dismiss()}
|
|
|
|
|
+ >
|
|
|
|
|
+ {t('buttons.gotIt')}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ )}
|
|
|
</div>
|
|
</div>
|
|
|
</PopoverContent>
|
|
</PopoverContent>
|
|
|
</Popover>
|
|
</Popover>
|