@estiva-app/ui 0.44.1 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/dist/CollapsibleSection.d.ts +7 -1
  2. package/dist/CollapsibleSection.d.ts.map +1 -1
  3. package/dist/InlineChip.d.ts +1 -1
  4. package/dist/NavItem.d.ts +11 -1
  5. package/dist/NavItem.d.ts.map +1 -1
  6. package/dist/SectionHeader.d.ts +9 -1
  7. package/dist/SectionHeader.d.ts.map +1 -1
  8. package/dist/SelectionToolbar.d.ts +62 -0
  9. package/dist/SelectionToolbar.d.ts.map +1 -0
  10. package/dist/SuggestionMenu.d.ts +75 -0
  11. package/dist/SuggestionMenu.d.ts.map +1 -0
  12. package/dist/editor.d.ts +12 -0
  13. package/dist/editor.d.ts.map +1 -0
  14. package/dist/editor.js +357 -0
  15. package/dist/editor.js.map +7 -0
  16. package/dist/eslint/index.js +4 -3
  17. package/dist/eslint/index.js.map +2 -2
  18. package/dist/eslint/no-restyled-part.d.ts.map +1 -1
  19. package/dist/index.d.ts +2 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +274 -133
  22. package/dist/index.js.map +4 -4
  23. package/dist/looks.d.ts +3 -0
  24. package/dist/looks.d.ts.map +1 -1
  25. package/dist/registry/{app-YMABQII6.js → app-LDGOH6UW.js} +3 -3
  26. package/dist/registry/{build-3EANARLQ.js → build-P6X3PTZP.js} +6 -2
  27. package/dist/registry/build.d.ts +4 -0
  28. package/dist/registry/build.d.ts.map +1 -1
  29. package/dist/registry/{chunk-SIB44CES.js → chunk-P55P2QFD.js} +9 -4
  30. package/dist/registry/chunk-P55P2QFD.js.map +7 -0
  31. package/dist/registry/{chunk-EVJW26UR.js → chunk-TQ6UQER3.js} +10 -10
  32. package/dist/registry/chunk-TQ6UQER3.js.map +7 -0
  33. package/dist/registry/cli.js +2 -2
  34. package/dist/registry/index.js +2 -2
  35. package/dist/shortcuts.d.ts +27 -0
  36. package/dist/shortcuts.d.ts.map +1 -0
  37. package/dist/suggestionPopup.d.ts +41 -0
  38. package/dist/suggestionPopup.d.ts.map +1 -0
  39. package/package.json +27 -2
  40. package/registry.json +391 -11
  41. package/src/Chip.tsx +1 -1
  42. package/src/CollapsibleSection.mdx +5 -0
  43. package/src/CollapsibleSection.stories.tsx +5 -0
  44. package/src/CollapsibleSection.tsx +10 -3
  45. package/src/CommandPalette.tsx +1 -1
  46. package/src/InlineChip.test.tsx +1 -1
  47. package/src/InlineChip.tsx +1 -1
  48. package/src/NavItem.mdx +9 -0
  49. package/src/NavItem.stories.tsx +23 -0
  50. package/src/NavItem.tsx +56 -6
  51. package/src/Reaction.tsx +1 -1
  52. package/src/SectionHeader.mdx +9 -0
  53. package/src/SectionHeader.stories.tsx +15 -0
  54. package/src/SectionHeader.tsx +28 -7
  55. package/src/SelectionToolbar.mdx +78 -0
  56. package/src/SelectionToolbar.stories.tsx +63 -0
  57. package/src/SelectionToolbar.test.ts +105 -0
  58. package/src/SelectionToolbar.tsx +487 -0
  59. package/src/SuggestionMenu.mdx +107 -0
  60. package/src/SuggestionMenu.stories.tsx +202 -0
  61. package/src/SuggestionMenu.test.tsx +128 -0
  62. package/src/SuggestionMenu.tsx +189 -0
  63. package/src/editor.ts +11 -0
  64. package/src/eslint/no-rebuilt-behaviour.test.ts +1 -1
  65. package/src/eslint/no-rebuilt-behaviour.ts +2 -2
  66. package/src/eslint/no-restyled-part.ts +2 -1
  67. package/src/how.test.ts +1 -1
  68. package/src/index.ts +2 -0
  69. package/src/keptSelection.test.ts +67 -0
  70. package/src/looks.ts +4 -0
  71. package/src/registry/app.ts +8 -8
  72. package/src/registry/build.ts +10 -2
  73. package/src/registry/registry.test.ts +14 -9
  74. package/src/shortcuts.ts +40 -0
  75. package/src/suggestionPopup.tsx +119 -0
  76. package/stories/Choosing.mdx +2 -0
  77. package/tailwind-preset.js +1 -0
  78. package/tokens.css +19 -4
  79. package/dist/registry/chunk-EVJW26UR.js.map +0 -7
  80. package/dist/registry/chunk-SIB44CES.js.map +0 -7
  81. /package/dist/registry/{app-YMABQII6.js.map → app-LDGOH6UW.js.map} +0 -0
  82. /package/dist/registry/{build-3EANARLQ.js.map → build-P6X3PTZP.js.map} +0 -0
package/src/NavItem.tsx CHANGED
@@ -1,5 +1,9 @@
1
1
  import type { ComponentPropsWithoutRef, ReactNode } from 'react'
2
+ import { IconDotsVertical } from '@tabler/icons-react'
2
3
  import { cn } from './cn'
4
+ import { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_TEXT_CLASSES } from './looks'
5
+ import { IconButton } from './IconButton'
6
+ import { Menu } from './Menu'
3
7
  import { WithTooltip } from './Tooltip'
4
8
 
5
9
  /**
@@ -12,6 +16,12 @@ import { WithTooltip } from './Tooltip'
12
16
  * count draws its zero (see Tabs) — both rulings stand, deliberately.
13
17
  *
14
18
  * Active is the same fill as a selected tab — `bg-active`, neutral.
19
+ *
20
+ * `menu` gives the row a "More options" menu (UIG-44, Katerina, 30
21
+ * September): a ⋮ that shows while the row is pointed at, focused or its
22
+ * menu is open, in the count's place — the count steps aside rather than
23
+ * sitting under it. The ⋮ is beside the link, not inside it (a button inside
24
+ * an anchor is not a button), and follows it in the Tab order.
15
25
  */
16
26
  export interface NavItemProps extends Omit<ComponentPropsWithoutRef<'a'>, 'href'> {
17
27
  label: string
@@ -23,12 +33,15 @@ export interface NavItemProps extends Omit<ComponentPropsWithoutRef<'a'>, 'href'
23
33
  active?: boolean
24
34
  /** 16px, stroke 1.5. */
25
35
  icon?: ReactNode
36
+ /** The row's "More options" menu: `MenuItem`s. Absent, the row has no menu and is exactly the plain row. */
37
+ menu?: ReactNode
38
+ /** The ⋮'s accessible name and tooltip. Default "More options for {label}". */
39
+ menuLabel?: string
26
40
  className?: string
27
41
  }
28
42
 
29
- export function NavItem({ label, href, count, countLabel, active = false, icon, className, ...props }: NavItemProps) {
30
- return (
31
- // @estiva-escape(no-copied-look): N5 (Katerina, 24 September: record only): a row that fills on hover, as PersonTrigger's row also does
43
+ export function NavItem({ label, href, count, countLabel, active = false, icon, menu, menuLabel, className, ...props }: NavItemProps) {
44
+ const row = (
32
45
  <a
33
46
  href={href}
34
47
  aria-current={active ? 'page' : undefined}
@@ -36,8 +49,12 @@ export function NavItem({ label, href, count, countLabel, active = false, icon,
36
49
  // shrink-0: the row keeps its 32px inside an overflowing flex column
37
50
  // — without it the list compresses instead of scrolling (the missing-
38
51
  // shrink-0 family; Katerina, 2026-09-02).
39
- 'flex h-8 min-w-0 shrink-0 items-center gap-2 rounded-md px-2 text-body-2 transition-colors',
40
- active ? 'bg-bg-active text-text-primary' : 'text-text-secondary hover:bg-bg-hover hover:text-text-primary',
52
+ 'flex h-8 min-w-0 shrink-0 items-center gap-2 px-2 text-body-2',
53
+ SIDEBAR_ROW_CLASSES,
54
+ active ? 'bg-bg-active text-text-primary' : cn(SIDEBAR_ROW_TEXT_CLASSES, 'hover:bg-bg-hover'),
55
+ // With a menu the ⋮ sits beside the link, so the row keeps its hover
56
+ // look while the ⋮ is pointed at and while its menu is open.
57
+ menu != null && !active && 'group-hover/nav:bg-bg-hover group-hover/nav:text-text-primary group-has-[[aria-expanded=true]]/nav:bg-bg-hover group-has-[[aria-expanded=true]]/nav:text-text-primary',
41
58
  className,
42
59
  )}
43
60
  {...props}
@@ -50,9 +67,42 @@ export function NavItem({ label, href, count, countLabel, active = false, icon,
50
67
  here sits on the same axis as a SectionHeader's action above it
51
68
  — right edges alone left a digit 4px off a 16px icon (Katerina,
52
69
  2026-09-09). A three-digit count grows the box leftwards. */}
53
- <span className="min-w-4 shrink-0 text-center font-mono text-caption tabular-nums text-text-muted">{count}</span>
70
+ <span
71
+ className={cn(
72
+ 'min-w-4 shrink-0 text-center font-mono text-caption tabular-nums text-text-muted',
73
+ // With a menu, the ⋮ takes this place while it shows.
74
+ menu != null && 'transition-opacity group-hover/nav:opacity-0 group-focus-within/nav:opacity-0 group-has-[[aria-expanded=true]]/nav:opacity-0',
75
+ )}
76
+ >
77
+ {count}
78
+ </span>
54
79
  </WithTooltip>
80
+ ) : menu != null ? (
81
+ // No count, but a menu: the count's place is kept free, so a long
82
+ // label truncates before the ⋮ instead of running under it.
83
+ <span aria-hidden="true" className="min-w-4 shrink-0" />
55
84
  ) : null}
56
85
  </a>
57
86
  )
87
+ if (menu == null) return row
88
+ const name = menuLabel ?? `More options for ${label}`
89
+ return (
90
+ <div className="group/nav relative shrink-0">
91
+ {row}
92
+ {/* `right-1`: the 24px button's 16px icon then ends 8px from the row's
93
+ edge, where the count ends (px-2), so the ⋮ lands on the number. */}
94
+ <div className="absolute inset-y-0 right-1 flex items-center opacity-0 transition-opacity focus-within:opacity-100 group-hover/nav:opacity-100 has-[[aria-expanded=true]]:opacity-100">
95
+ <Menu
96
+ align="right"
97
+ trigger={
98
+ <IconButton tooltip="More options" aria-label={name}>
99
+ <IconDotsVertical size={16} stroke={1.5} />
100
+ </IconButton>
101
+ }
102
+ >
103
+ {menu}
104
+ </Menu>
105
+ </div>
106
+ </div>
107
+ )
58
108
  }
package/src/Reaction.tsx CHANGED
@@ -81,7 +81,7 @@ export function Reaction({ emoji, count, pressed = false, className, ...props }:
81
81
  (Katerina, 2026-09-08). The number reads in the text colour
82
82
  there; the accent keeps the edge and the fill. Signal keeps
83
83
  Peek's blue-on-wash, which reads. */
84
- 'border-accent-primary bg-accent-muted text-accent-primary hover:border-accent-hover ship:text-text-primary'
84
+ 'border-accent-primary bg-accent-muted text-accent-text hover:border-accent-hover ship:text-text-primary'
85
85
  : 'border-border-default bg-bg-inset text-text-primary hover:border-border-strong hover:bg-bg-hover',
86
86
  className,
87
87
  )}
@@ -24,6 +24,15 @@ title the toggle — that is where a section folds.
24
24
 
25
25
  <Canvas of={SectionHeaderStories.PersistentActions} />
26
26
 
27
+ - `look="quiet"`: a small grey label over a list whose rows are rows, not
28
+ headings — "Folders" over the folders — with its action held on screen.
29
+ It never lights up under the pointer; its button does.
30
+
31
+ <Canvas of={SectionHeaderStories.Quiet} />
32
+
33
+ - `look="row"`: the header is a row among rows — a folder in a sidebar. Use
34
+ it through **CollapsibleSection**'s `look="row"`.
35
+
27
36
  ## When not
28
37
 
29
38
  - A section that opens and closes → **CollapsibleSection**: the header,
@@ -56,3 +56,18 @@ export const WithTrailing: Story = {
56
56
  actions: [{ icon: <IconPlus size={16} stroke={1.5} />, tooltip: 'Add', onClick: () => {} }],
57
57
  },
58
58
  }
59
+
60
+ /** `look="quiet"`: a small grey label over a list, with its action always on screen — "Folders" over the folders. The row never lights up; the button does. */
61
+ export const Quiet: Story = {
62
+ args: {
63
+ title: 'Folders',
64
+ look: 'quiet',
65
+ showActions: 'always',
66
+ actions: [{ icon: <IconPlus size={16} stroke={1.5} />, tooltip: 'New folder', onClick: () => {} }],
67
+ },
68
+ }
69
+
70
+ /** `look="row"`: a row among rows — the chevron and the title at a sidebar row's size and colour, brightening under the pointer. CollapsibleSection's `look="row"` draws this. */
71
+ export const Row: Story = {
72
+ args: { title: 'Folder', look: 'row', chevron: true, isExpanded: true, onToggle: () => {} },
73
+ }
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react'
2
2
  import { IconChevronRight } from '@tabler/icons-react'
3
3
  import { useRender } from '@base-ui/react/use-render'
4
4
  import { cn } from './cn'
5
+ import { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_TEXT_CLASSES } from './looks'
5
6
  import { IconButton } from './IconButton'
6
7
  import { SectionLabel } from './SectionLabel'
7
8
 
@@ -65,6 +66,14 @@ export interface SectionHeaderProps {
65
66
  * where the buttons light up on their own (UIG-14, Katerina, 19 September).
66
67
  */
67
68
  hover?: 'fill' | 'none'
69
+ /**
70
+ * `heading` (default): a section's title, the `SectionLabel`. `quiet`: a
71
+ * small grey label over a list (12px, muted) — "Folders" over the folders,
72
+ * which never reads as one of them. `row`: a row among rows — the chevron
73
+ * and the title at a row's weight and colour, as a sidebar row is (Katerina,
74
+ * 30 September: Leaf's folders, in Ship).
75
+ */
76
+ look?: 'heading' | 'quiet' | 'row'
68
77
  /**
69
78
  * What the title renders as, in Base UI's manner. A plain button with
70
79
  * `onToggle` by default; `CollapsibleSection` hands in `Collapsible.Trigger`.
@@ -73,24 +82,32 @@ export interface SectionHeaderProps {
73
82
  className?: string
74
83
  }
75
84
 
76
- export function SectionHeader({ title, chevron = false, isExpanded = true, onToggle, trailing, actions, showActions = 'hover', hover = 'fill', render, className }: SectionHeaderProps) {
85
+ export function SectionHeader({ title, chevron = false, isExpanded = true, onToggle, trailing, actions, showActions = 'hover', hover = 'fill', look = 'heading', render, className }: SectionHeaderProps) {
86
+ const row = look === 'row'
77
87
  const titleElement = useRender({
78
88
  render: render ?? (chevron ? <button type="button" onClick={onToggle} aria-expanded={isExpanded} /> : <span />),
79
89
  props: {
80
90
  // `text-left`: a button centres its text. `h-full` and `flex-1`: the
81
91
  // whole row up to the actions is the hit target, as it was when the
82
92
  // row itself carried the click.
83
- className: 'flex h-full min-w-0 flex-1 items-center gap-1 text-left',
93
+ className: cn('flex h-full min-w-0 flex-1 items-center text-left', row ? 'gap-2' : 'gap-1'),
84
94
  children: (
85
95
  <>
86
96
  {chevron && (
87
97
  <IconChevronRight
88
- size={12}
98
+ size={row ? 16 : 12}
89
99
  stroke={1.5}
90
- className={cn('shrink-0 text-text-secondary transition-transform duration-150', isExpanded && 'rotate-90')}
100
+ className={cn('shrink-0 transition-transform duration-150', !row && 'text-text-secondary', isExpanded && 'rotate-90')}
91
101
  />
92
102
  )}
93
- <SectionLabel>{title}</SectionLabel>
103
+ {look === 'quiet' ? (
104
+ <span className="min-w-0 truncate text-caption text-text-muted">{title}</span>
105
+ ) : row ? (
106
+ // A row's words: NavItem's size and colour, brightening under the pointer with the row.
107
+ <span className="min-w-0 flex-1 truncate text-body-2">{title}</span>
108
+ ) : (
109
+ <SectionLabel>{title}</SectionLabel>
110
+ )}
94
111
  </>
95
112
  ),
96
113
  },
@@ -99,11 +116,15 @@ export function SectionHeader({ title, chevron = false, isExpanded = true, onTog
99
116
  return (
100
117
  <div
101
118
  className={cn(
102
- 'group flex h-[32px] shrink-0 items-center gap-1 rounded-lg px-2 transition-colors',
119
+ 'group flex h-[32px] shrink-0 items-center gap-1 px-2',
120
+ !row && 'transition-colors',
121
+ // A row is NavItem's shape and colour: 6px corners, secondary words that brighten on hover.
122
+ row ? cn(SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_TEXT_CLASSES) : 'rounded-lg',
103
123
  // The fill says "this does something": a row with a toggle or actions
104
124
  // lights up, a fixed heading over rows does not (2026-09-09, the
105
125
  // Sidebar's fixed group).
106
- hover === 'fill' && (chevron || (actions && actions.length > 0)) && 'hover:bg-bg-hover',
126
+ // A quiet label never lights up: it is a label; its button does.
127
+ hover === 'fill' && look !== 'quiet' && (chevron || (actions && actions.length > 0)) && 'hover:bg-bg-hover',
107
128
  className,
108
129
  )}
109
130
  >
@@ -0,0 +1,78 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as SelectionToolbarStories from './SelectionToolbar.stories'
3
+
4
+ <Meta of={SelectionToolbarStories} />
5
+
6
+ # SelectionToolbar
7
+
8
+ Bold, italic and underline — and code or a link where the editor has them —
9
+ over text you have already selected in an editor.
10
+
11
+ <Canvas of={SelectionToolbarStories.Default} />
12
+
13
+ ## When
14
+
15
+ - **Inside any Tiptap editor.** It mounts with the editor and decides for
16
+ itself when to appear: once a selection has stood still, centred over it.
17
+ - `marks` chooses the buttons, in order: `bold`, `italic`, `underline`, `code`.
18
+ The default is the first three.
19
+ - `link` adds the Link button, which turns the strip into one field for the
20
+ address. Select words that are already a link and the field opens by itself,
21
+ their address in it.
22
+
23
+ ## When not
24
+
25
+ - In the `/` list → **SuggestionMenu**. Typing `/` replaces the selection, so
26
+ there is nothing selected to apply a mark to.
27
+ - A strip of actions on a card or an image → **Toolbar** in a **Popover**. This
28
+ one's placement exists to centre over a text selection.
29
+
30
+ ## How
31
+
32
+ ```tsx
33
+ import { EditorContent, useEditor } from '@tiptap/react'
34
+ import StarterKit from '@tiptap/starter-kit'
35
+ import { SelectionToolbar, KeptSelection } from '@estiva-app/ui/editor'
36
+
37
+ const editor = useEditor({ extensions: [StarterKit, KeptSelection], content: text })
38
+
39
+ <EditorContent editor={editor} />
40
+ <SelectionToolbar editor={editor} marks={['bold', 'italic', 'underline', 'code']} link />
41
+ ```
42
+
43
+ - `editor` is the Tiptap editor, or `null` while it loads. There is nothing to
44
+ place: it anchors itself to the selection.
45
+ - **`link` needs two things in the editor**: a link mark, and `KeptSelection`,
46
+ which keeps the words highlighted while the address field has the focus.
47
+ Without a link mark, leave `link` off.
48
+ - **Each mark must be in the editor's schema.** A button for a mark the editor
49
+ lacks does nothing.
50
+ - **The address field:** Enter applies it, and so does clicking away. Enter on
51
+ an empty field removes the link; clicking away with an empty field changes
52
+ nothing. Escape drops what was typed. `http`, `https` and `mailto` only; a
53
+ bare host gets `https://`.
54
+
55
+ <Canvas of={SelectionToolbarStories.WithALink} />
56
+
57
+ ## What it owns
58
+
59
+ | It does | So you never write | Refused when written by hand |
60
+ |---|---|---|
61
+ | Appears once a selection has stood still, never mid-drag or on a plain click | a selection listener, a timer | |
62
+ | Centres itself over the selection, above it | position arithmetic | |
63
+ | Keeps the caret in the text while a button is pressed | `preventDefault` on each button | |
64
+ | Keeps the words highlighted while the address field has focus | a second selection | |
65
+
66
+ ## Keys
67
+
68
+ | Input | What happens |
69
+ |---|---|
70
+ | Cmd/Ctrl+B, I, U, E | bold, italic, underline, code — the editor's own keys, named in each tooltip |
71
+ | Tab | onto the strip, as any `Toolbar` |
72
+ | ← / → | between the buttons |
73
+ | Enter, in the address field | applies it; empty removes the link |
74
+ | Escape, in the address field | drops what was typed and puts the caret back |
75
+
76
+ ## Props
77
+
78
+ <Controls of={SelectionToolbarStories.Default} />
@@ -0,0 +1,63 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { EditorContent, useEditor } from '@tiptap/react'
3
+ import StarterKit from '@tiptap/starter-kit'
4
+ import { richTextClassName } from './RichText'
5
+ import { KeptSelection, SelectionToolbar, type MarkId } from './SelectionToolbar'
6
+
7
+ /**
8
+ * Bold, italic, underline — and code or link where the editor has them — over
9
+ * text you have already selected. It follows the selection by itself.
10
+ */
11
+ const meta = {
12
+ title: 'Primitives/SelectionToolbar',
13
+ component: SelectionToolbar,
14
+ parameters: { layout: 'padded' },
15
+ } satisfies Meta
16
+
17
+ export default meta
18
+ type Story = StoryObj
19
+
20
+ /**
21
+ * A real editor with a few words selected as it opens, so the strip is on
22
+ * screen without a drag — it appears the way it always does, once the
23
+ * selection has stood still. Select other words to move it.
24
+ */
25
+ function WithAnEditor({ marks, link, html, select }: { marks?: readonly MarkId[]; link?: boolean; html: string; select: [number, number] }) {
26
+ const editor = useEditor({
27
+ extensions: [StarterKit, KeptSelection],
28
+ content: html,
29
+ editorProps: { attributes: { class: richTextClassName('default', 'outline-none'), 'aria-label': 'Text' } },
30
+ onCreate: ({ editor: e }) => {
31
+ e.commands.focus()
32
+ e.commands.setTextSelection({ from: select[0], to: select[1] })
33
+ },
34
+ })
35
+ return (
36
+ <div className="pt-16">
37
+ <EditorContent editor={editor} className="max-w-[560px]" />
38
+ <SelectionToolbar editor={editor} marks={marks} link={link} />
39
+ </div>
40
+ )
41
+ }
42
+
43
+ const TEXT = '<p>Select a few words in this line, and the strip appears over them.</p>'
44
+
45
+ /** The default strip: bold, italic, underline. */
46
+ export const Default: Story = {
47
+ render: () => <WithAnEditor html={TEXT} select={[10, 19]} />,
48
+ }
49
+
50
+ /** `marks` chooses the buttons: here code as well. */
51
+ export const WithCode: Story = {
52
+ render: () => <WithAnEditor html={TEXT} select={[10, 19]} marks={['bold', 'italic', 'underline', 'code']} />,
53
+ }
54
+
55
+ /** `link` adds the Link button, which opens one field for the address. */
56
+ export const WithALink: Story = {
57
+ render: () => <WithAnEditor html={TEXT} select={[10, 19]} link />,
58
+ }
59
+
60
+ /** Words that are already a link open straight on the field, their address in it. */
61
+ export const OnALink: Story = {
62
+ render: () => <WithAnEditor html={'<p>Select <a href="https://example.com">these words</a> to change where they go.</p>'} select={[8, 19]} link />,
63
+ }
@@ -0,0 +1,105 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * The selection toolbar's helpers (UIG-31), carried over with it: how a key is
4
+ * named in a tooltip, and what the address field does to the text. Drawing the
5
+ * strip needs a real selection standing still, which jsdom cannot make; the
6
+ * stories show it in a browser.
7
+ */
8
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
9
+ import { Editor } from '@tiptap/core'
10
+ import StarterKit from '@tiptap/starter-kit'
11
+ import { KeptSelection, applyLink, currentLink, normalizeHref } from './SelectionToolbar'
12
+ import { shortcutLabel } from './shortcuts'
13
+
14
+ let editor: Editor
15
+
16
+ beforeEach(() => {
17
+ editor = new Editor({ extensions: [StarterKit, KeptSelection], content: '<p>read the linked words first</p>' })
18
+ })
19
+
20
+ afterEach(() => {
21
+ editor.destroy()
22
+ })
23
+
24
+ describe('shortcutLabel', () => {
25
+ it('says Cmd on Apple platforms', () => {
26
+ expect(shortcutLabel('Mod-B', true)).toBe('Cmd+B')
27
+ expect(shortcutLabel('Mod-Shift-B', true)).toBe('Cmd+Shift+B')
28
+ })
29
+
30
+ it('says Ctrl everywhere else', () => {
31
+ expect(shortcutLabel('Mod-B', false)).toBe('Ctrl+B')
32
+ expect(shortcutLabel('Mod-Shift-B', false)).toBe('Ctrl+Shift+B')
33
+ })
34
+
35
+ it('upper-cases a single-character key so Ctrl+b never reaches a tooltip', () => {
36
+ expect(shortcutLabel('Mod-b', false)).toBe('Ctrl+B')
37
+ })
38
+ })
39
+
40
+ describe('the marks the strip can toggle are in a standard editor', () => {
41
+ it.each(['bold', 'italic', 'underline', 'code', 'link'])('%s', (mark) => {
42
+ expect(Object.keys(editor.schema.marks)).toContain(mark)
43
+ })
44
+ })
45
+
46
+ describe('normalizeHref — what typing in the field becomes', () => {
47
+ const cases: Array<[string, string | null]> = [
48
+ ['https://example.com/a/b', 'https://example.com/a/b'],
49
+ ['http://example.com', 'http://example.com'],
50
+ ['mailto:someone@example.com', 'mailto:someone@example.com'],
51
+ ['example.com', 'https://example.com'],
52
+ [' www.example.com/a?b=c ', 'https://www.example.com/a?b=c'],
53
+ ['localhost:5173/page', 'https://localhost:5173/page'],
54
+ ['javascript:alert(1)', null],
55
+ ['ftp://files.example.com', null],
56
+ ['two words', null],
57
+ ['', null],
58
+ [' ', null],
59
+ ]
60
+ it.each(cases)('%j → %j', (raw, href) => {
61
+ expect(normalizeHref(raw)).toBe(href)
62
+ })
63
+ })
64
+
65
+ describe('applyLink — over "linked words" in "read the linked words first"', () => {
66
+ /** The one linked run in the document, or null. */
67
+ const linkOf = (): { text: string; href: string } | null => {
68
+ let found: { text: string; href: string } | null = null
69
+ editor.state.doc.descendants((node) => {
70
+ const link = node.marks.find((m) => m.type.name === 'link')
71
+ if (node.isText && link) found = { text: node.text ?? '', href: link.attrs.href as string }
72
+ })
73
+ return found
74
+ }
75
+
76
+ beforeEach(() => {
77
+ editor.commands.setTextSelection({ from: 10, to: 22 })
78
+ })
79
+
80
+ it('an address links the selected words, completing a bare host', () => {
81
+ expect(applyLink(editor, 'example.com')).toBe(true)
82
+ expect(linkOf()).toEqual({ text: 'linked words', href: 'https://example.com' })
83
+ expect(currentLink(editor)).toBe('https://example.com')
84
+ })
85
+
86
+ it('an empty field removes the link', () => {
87
+ applyLink(editor, 'https://example.com')
88
+ expect(applyLink(editor, '')).toBe(true)
89
+ expect(linkOf()).toBeNull()
90
+ expect(currentLink(editor)).toBe('')
91
+ })
92
+
93
+ it('a scheme a reader may not follow is refused, and nothing changes', () => {
94
+ expect(applyLink(editor, 'javascript:alert(1)')).toBe(false)
95
+ expect(linkOf()).toBeNull()
96
+ })
97
+
98
+ it('the caret inside a link re-addresses the whole link', () => {
99
+ applyLink(editor, 'https://example.com')
100
+ editor.commands.setTextSelection(15)
101
+ expect(currentLink(editor)).toBe('https://example.com')
102
+ expect(applyLink(editor, 'https://example.org')).toBe(true)
103
+ expect(linkOf()).toEqual({ text: 'linked words', href: 'https://example.org' })
104
+ })
105
+ })