@charcuterie/ui 4.25.0 → 4.26.1

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 (54) hide show
  1. package/README.md +13 -0
  2. package/dist/CommandPalette/CommandPalette.d.ts +43 -0
  3. package/dist/CommandPalette/CommandPalette.d.ts.map +1 -0
  4. package/dist/CommandPalette/CommandPalette.js +53 -0
  5. package/dist/CommandPalette/CommandPalette.js.map +1 -0
  6. package/dist/CommandPalette/CommandPaletteFooter.d.ts +16 -0
  7. package/dist/CommandPalette/CommandPaletteFooter.d.ts.map +1 -0
  8. package/dist/CommandPalette/CommandPaletteFooter.js +14 -0
  9. package/dist/CommandPalette/CommandPaletteFooter.js.map +1 -0
  10. package/dist/CommandPalette/CommandPaletteOption.d.ts +44 -0
  11. package/dist/CommandPalette/CommandPaletteOption.d.ts.map +1 -0
  12. package/dist/CommandPalette/CommandPaletteOption.js +72 -0
  13. package/dist/CommandPalette/CommandPaletteOption.js.map +1 -0
  14. package/dist/CommandPalette/CommandPalettePanel.d.ts +52 -0
  15. package/dist/CommandPalette/CommandPalettePanel.d.ts.map +1 -0
  16. package/dist/CommandPalette/CommandPalettePanel.js +168 -0
  17. package/dist/CommandPalette/CommandPalettePanel.js.map +1 -0
  18. package/dist/CommandPalette/commandPaletteSections.d.ts +93 -0
  19. package/dist/CommandPalette/commandPaletteSections.d.ts.map +1 -0
  20. package/dist/CommandPalette/commandPaletteSections.js +94 -0
  21. package/dist/CommandPalette/commandPaletteSections.js.map +1 -0
  22. package/dist/CommandPalette/useCommandPalette.d.ts +32 -0
  23. package/dist/CommandPalette/useCommandPalette.d.ts.map +1 -0
  24. package/dist/CommandPalette/useCommandPalette.js +40 -0
  25. package/dist/CommandPalette/useCommandPalette.js.map +1 -0
  26. package/dist/CommandPalette/useCommandPaletteHotkeys.d.ts +35 -0
  27. package/dist/CommandPalette/useCommandPaletteHotkeys.d.ts.map +1 -0
  28. package/dist/CommandPalette/useCommandPaletteHotkeys.js +114 -0
  29. package/dist/CommandPalette/useCommandPaletteHotkeys.js.map +1 -0
  30. package/dist/TableViewport/TableViewport.d.ts.map +1 -1
  31. package/dist/TableViewport/TableViewport.js +62 -10
  32. package/dist/TableViewport/TableViewport.js.map +1 -1
  33. package/dist/index.d.ts +7 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +3 -0
  36. package/dist/index.js.map +1 -1
  37. package/package.json +2 -2
  38. package/src/CommandPalette/CommandPalette.mdx +129 -0
  39. package/src/CommandPalette/CommandPalette.stories.tsx +413 -0
  40. package/src/CommandPalette/CommandPalette.test.tsx +553 -0
  41. package/src/CommandPalette/CommandPalette.tsx +91 -0
  42. package/src/CommandPalette/CommandPaletteFooter.tsx +45 -0
  43. package/src/CommandPalette/CommandPaletteOption.tsx +200 -0
  44. package/src/CommandPalette/CommandPalettePanel.tsx +411 -0
  45. package/src/CommandPalette/commandPalette.storyHelpers.tsx +248 -0
  46. package/src/CommandPalette/commandPaletteSections.test.ts +156 -0
  47. package/src/CommandPalette/commandPaletteSections.ts +219 -0
  48. package/src/CommandPalette/useCommandPalette.ts +75 -0
  49. package/src/CommandPalette/useCommandPaletteHotkeys.ts +158 -0
  50. package/src/TableViewport/TableViewport.mdx +2 -0
  51. package/src/TableViewport/TableViewport.test.tsx +89 -1
  52. package/src/TableViewport/TableViewport.tsx +82 -9
  53. package/src/index.ts +14 -0
  54. package/src/sourceRules.test.ts +2 -1
@@ -0,0 +1,158 @@
1
+ import { useLatestRef } from "@charcuterie/logic"
2
+ import { useEffect } from "react"
3
+
4
+ export type CommandPaletteHotkeysOptions = {
5
+ /** Off while the palette is open, or on a page that should not take the keys. Default `true`. */
6
+ isEnabled?: boolean
7
+ /**
8
+ * A printable key typed outside an editable field opens the palette
9
+ * with that character as the query. Default `true`.
10
+ */
11
+ isOpenedByTyping?: boolean
12
+ /** Receives the text the palette should open with: `""`, or the character typed. */
13
+ onOpen: (initialQuery: string) => void
14
+ }
15
+
16
+ /**
17
+ * Elements that keep their keys: anything you type into, and the
18
+ * widgets with their own type-to-find (a focused `Listbox` or `Menu`
19
+ * jumps to the option you type, and stealing that key breaks it).
20
+ */
21
+ const TYPEAHEAD_OWNER_SELECTOR = [
22
+ "input",
23
+ "textarea",
24
+ "select",
25
+ "[contenteditable]:not([contenteditable='false'])",
26
+ "[role='combobox']",
27
+ "[role='grid']",
28
+ "[role='listbox']",
29
+ "[role='menu']",
30
+ "[role='menubar']",
31
+ "[role='searchbox']",
32
+ "[role='textbox']",
33
+ "[role='tree']",
34
+ "[role='treegrid']",
35
+ ].join(",")
36
+
37
+ /** Input types that take no text, so a letter typed on one is free to open the palette. */
38
+ const NON_TEXT_INPUT_TYPES = new Set([
39
+ "button",
40
+ "checkbox",
41
+ "color",
42
+ "file",
43
+ "image",
44
+ "radio",
45
+ "range",
46
+ "reset",
47
+ "submit",
48
+ ])
49
+
50
+ export const getIsTypingTarget = (
51
+ target: EventTarget | null,
52
+ ): boolean => {
53
+ if (!(target instanceof Element)) {
54
+ return false
55
+ }
56
+
57
+ if (
58
+ target instanceof HTMLInputElement &&
59
+ NON_TEXT_INPUT_TYPES.has(target.type)
60
+ ) {
61
+ return false
62
+ }
63
+
64
+ return (
65
+ (target instanceof HTMLElement &&
66
+ target.isContentEditable) ||
67
+ target.closest(TYPEAHEAD_OWNER_SELECTOR) !== null
68
+ )
69
+ }
70
+
71
+ /**
72
+ * The global keys that open a command palette, as an opt-in hook.
73
+ *
74
+ * - **`Ctrl K`, `Ctrl Space`** (or `⌘` on a Mac) open it from
75
+ * anywhere, a text field included — the chord types nothing.
76
+ * - **`/`** opens it when focus is not somewhere that types, and
77
+ * the slash itself is not typed.
78
+ * - **Any printable key** does the same and becomes the query, so
79
+ * the page is a search box without a click (`isOpenedByTyping`).
80
+ *
81
+ * It is a hook, not something `CommandPalette` does on mount,
82
+ * because a document-wide key listener is a page-level decision: an
83
+ * app with two palettes, or a page where letters already mean
84
+ * something (a board with its own shortcuts), must be able to say
85
+ * no. See
86
+ * `docs/decisions/2026-10-16-command-palette-hotkeys-are-an-opt-in-hook.md`.
87
+ *
88
+ * A key another handler already claimed (`defaultPrevented`), a key
89
+ * pressed during IME composition, and a key with `Alt` held are all
90
+ * left alone.
91
+ */
92
+ export const useCommandPaletteHotkeys = ({
93
+ isEnabled = true,
94
+ isOpenedByTyping = true,
95
+ onOpen,
96
+ }: CommandPaletteHotkeysOptions): void => {
97
+ const onOpenRef = useLatestRef(onOpen)
98
+
99
+ useEffect(() => {
100
+ if (!isEnabled) {
101
+ return
102
+ }
103
+
104
+ const handleKeyDown = (event: KeyboardEvent) => {
105
+ if (
106
+ event.defaultPrevented ||
107
+ event.isComposing ||
108
+ event.altKey
109
+ ) {
110
+ return
111
+ }
112
+
113
+ const isChord = event.ctrlKey || event.metaKey
114
+
115
+ if (isChord) {
116
+ if (
117
+ !event.shiftKey &&
118
+ (event.key.toLowerCase() === "k" ||
119
+ event.key === " ")
120
+ ) {
121
+ event.preventDefault()
122
+ onOpenRef.current("")
123
+ }
124
+
125
+ return
126
+ }
127
+
128
+ if (getIsTypingTarget(event.target)) {
129
+ return
130
+ }
131
+
132
+ if (event.key === "/") {
133
+ event.preventDefault()
134
+ onOpenRef.current("")
135
+
136
+ return
137
+ }
138
+
139
+ // `key` is the character for a printable key and a name
140
+ // ("Enter", "ArrowDown") for everything else; a space is
141
+ // printable but presses the focused button.
142
+ if (
143
+ isOpenedByTyping &&
144
+ [...event.key].length === 1 &&
145
+ /\S/u.test(event.key)
146
+ ) {
147
+ event.preventDefault()
148
+ onOpenRef.current(event.key)
149
+ }
150
+ }
151
+
152
+ document.addEventListener("keydown", handleKeyDown)
153
+
154
+ return () => {
155
+ document.removeEventListener("keydown", handleKeyDown)
156
+ }
157
+ }, [isEnabled, isOpenedByTyping, onOpenRef])
158
+ }
@@ -10,6 +10,8 @@ its rows and uses the page's vertical scroll. Expand table opens a full-window `
10
10
  The dialog body is the only scroll surface; headers and the first column stay visible.
11
11
  Escape and Close return focus to the trigger. Only one table is mounted, so links and IDs
12
12
  are never duplicated. The inline height and horizontal position survive expansion.
13
+ Expansion fades and zooms in; dismissal reverses the animation before returning to the
14
+ document. Reduced-motion and zero-duration profiles skip the animation.
13
15
 
14
16
  Apps provide the semantic table, its column count, and its accessible label. Sanitizing
15
17
  Markdown HTML and delegating document links remain the app's responsibilities. Use
@@ -1,6 +1,6 @@
1
1
  import { composeStories } from "@storybook/react"
2
2
  import { expect, userEvent, waitFor } from "storybook/test"
3
- import { test } from "vitest"
3
+ import { test, vi } from "vitest"
4
4
  import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
5
5
  import { mountStory } from "../mountStory.testHelpers.ts"
6
6
  import { expectAgentDrivable } from "../testing/index.ts"
@@ -26,6 +26,8 @@ test("long inline comparisons grow with the page and expand without duplicate ta
26
26
  role: "dialog",
27
27
  name: "Printer comparison",
28
28
  })
29
+ for (const animation of dialog.getAnimations())
30
+ animation.finish()
29
31
  await expect(
30
32
  document.querySelectorAll("table").length,
31
33
  ).toBe(1)
@@ -56,3 +58,89 @@ test("long inline comparisons grow with the page and expand without duplicate ta
56
58
  canvas.getByRole("table"),
57
59
  ).toBeInTheDocument()
58
60
  })
61
+
62
+ test("keeps the table and focus trap until the reverse animation completes", async () => {
63
+ const { body, canvas } = await mountStory(Interactive)
64
+ const trigger = canvas.getByRole("button", {
65
+ name: "Expand table",
66
+ })
67
+ const pane = canvas.getByRole("region", {
68
+ name: "Printer comparison",
69
+ })
70
+ pane.scrollLeft = 120
71
+ const scrollLeft = pane.scrollLeft
72
+ const height = pane.getBoundingClientRect().height
73
+ await userEvent.click(trigger)
74
+ const dialog = body.getByRole("dialog", {
75
+ name: "Printer comparison",
76
+ })
77
+ const [animation] = dialog.getAnimations()
78
+ await expect(animation).toBeDefined()
79
+ animation.pause()
80
+ animation.currentTime =
81
+ Number(animation.effect?.getTiming().duration) / 2
82
+ await expect(
83
+ Number(getComputedStyle(dialog).opacity),
84
+ ).toBeGreaterThan(0)
85
+ await expect(
86
+ Number(getComputedStyle(dialog).opacity),
87
+ ).toBeLessThan(1)
88
+ await expect(pane.getBoundingClientRect().height).toBe(
89
+ height,
90
+ )
91
+ await userEvent.keyboard("{Escape}")
92
+ await animation.ready
93
+ animation.pause()
94
+ await expect(animation.playbackRate).toBe(-1)
95
+ await expect(body.getByRole("dialog")).toBe(dialog)
96
+ await expect(
97
+ document.querySelectorAll("table"),
98
+ ).toHaveLength(1)
99
+ // A second dismiss during exit must not reverse back into an opening.
100
+ await userEvent.click(
101
+ body.getByRole("button", { name: "Close" }),
102
+ )
103
+ await expect(animation.playbackRate).toBe(-1)
104
+ animation.finish()
105
+ await waitFor(() =>
106
+ expect(body.queryByRole("dialog")).toBeNull(),
107
+ )
108
+ await waitFor(() =>
109
+ expect(document.activeElement).toBe(trigger),
110
+ )
111
+ await waitFor(() =>
112
+ expect(pane.scrollLeft).toBe(scrollLeft),
113
+ )
114
+ await expect(
115
+ canvas.getByRole("table"),
116
+ ).toBeInTheDocument()
117
+ })
118
+
119
+ test("reduced motion opens and closes immediately without a table animation", async () => {
120
+ const { body, canvas } = await mountStory(Interactive)
121
+ const matchMedia = window.matchMedia.bind(window)
122
+ const media = vi
123
+ .spyOn(window, "matchMedia")
124
+ .mockImplementation((query) =>
125
+ query === "(prefers-reduced-motion: reduce)"
126
+ ? { ...matchMedia(query), matches: true }
127
+ : matchMedia(query),
128
+ )
129
+ try {
130
+ await userEvent.click(
131
+ canvas.getByRole("button", { name: "Expand table" }),
132
+ )
133
+ const dialog = body.getByRole("dialog", {
134
+ name: "Printer comparison",
135
+ })
136
+ await expect(dialog.getAnimations()).toHaveLength(0)
137
+ await userEvent.click(
138
+ body.getByRole("button", { name: "Close" }),
139
+ )
140
+ await waitFor(() =>
141
+ expect(body.queryByRole("dialog")).toBeNull(),
142
+ )
143
+ } finally {
144
+ media.mockRestore()
145
+ }
146
+ })
@@ -1,5 +1,10 @@
1
1
  import type { CSSProperties, ReactNode } from "react"
2
- import { useRef, useState } from "react"
2
+ import {
3
+ useCallback,
4
+ useLayoutEffect,
5
+ useRef,
6
+ useState,
7
+ } from "react"
3
8
  import { Button } from "../Button/Button.tsx"
4
9
  import { Dialog } from "../Dialog/Dialog.tsx"
5
10
  import { toClassName } from "../toClassName.ts"
@@ -23,12 +28,87 @@ export const TableViewport = ({
23
28
  const [inlineHeight, setInlineHeight] = useState(0)
24
29
  const scrollRef = useRef<HTMLElement>(null)
25
30
  const scrollPosition = useRef(0)
31
+ const expansion = useRef<Animation | null>(null)
32
+ const isClosing = useRef(false)
33
+ useLayoutEffect(() => {
34
+ // Restore after React has actually returned the table to this pane.
35
+ // A frame scheduled from the animation's promise can run before that commit
36
+ // on a busy renderer, when the empty pane clamps scrollLeft back to zero.
37
+ if (!isExpanded && scrollRef.current)
38
+ scrollRef.current.scrollLeft = scrollPosition.current
39
+ }, [isExpanded])
40
+ const animateExpansion = useCallback(
41
+ (node: HTMLDivElement | null) => {
42
+ const dialog = node?.closest<HTMLElement>(
43
+ '[role="dialog"]',
44
+ )
45
+ if (
46
+ !dialog ||
47
+ matchMedia("(prefers-reduced-motion: reduce)")
48
+ .matches
49
+ )
50
+ return
51
+
52
+ const style = getComputedStyle(dialog)
53
+ const duration = Number.parseFloat(
54
+ style.getPropertyValue("--duration-slow"),
55
+ )
56
+ // Zero-duration profiles (including ePaper) remove motion entirely.
57
+ if (!(duration > 0)) return
58
+
59
+ const animation = dialog.animate(
60
+ [
61
+ {
62
+ opacity: 0,
63
+ transform: "translateY(0.75rem) scale(0.98)",
64
+ },
65
+ { opacity: 1, transform: "none" },
66
+ ],
67
+ {
68
+ duration,
69
+ easing:
70
+ style
71
+ .getPropertyValue("--easing-standard")
72
+ .trim() || "ease-out",
73
+ fill: "both",
74
+ },
75
+ )
76
+ expansion.current = animation
77
+ // Unmounting a document can cancel an unfinished enter or exit.
78
+ void animation.finished.catch(() => {})
79
+ return () => {
80
+ animation.cancel()
81
+ expansion.current = null
82
+ }
83
+ },
84
+ [],
85
+ )
86
+ const close = () => {
87
+ if (isClosing.current) return
88
+ isClosing.current = true
89
+ const finish = () => {
90
+ setIsExpanded(false)
91
+ isClosing.current = false
92
+ }
93
+ const animation = expansion.current
94
+ if (
95
+ !animation ||
96
+ matchMedia("(prefers-reduced-motion: reduce)").matches
97
+ ) {
98
+ finish()
99
+ return
100
+ }
101
+ // Reversing also handles Escape during the opening animation without a jump.
102
+ animation.reverse()
103
+ void animation.finished.then(finish, () => {})
104
+ }
26
105
  const contentStyle = {
27
106
  "--table-min-inline-size": `${Math.max(1, columnCount) * 14}rem`,
28
107
  } as CSSProperties
29
108
  const content = (
30
109
  <div
31
110
  className="charcuterie-table-content"
111
+ ref={isExpanded ? animateExpansion : undefined}
32
112
  style={contentStyle}
33
113
  >
34
114
  {children}
@@ -70,14 +150,7 @@ export const TableViewport = ({
70
150
  heading={label}
71
151
  isVisible={isExpanded}
72
152
  size="full"
73
- onClose={() => {
74
- setIsExpanded(false)
75
- requestAnimationFrame(() => {
76
- if (scrollRef.current)
77
- scrollRef.current.scrollLeft =
78
- scrollPosition.current
79
- })
80
- }}
153
+ onClose={close}
81
154
  >
82
155
  {isExpanded ? content : null}
83
156
  </Dialog>
package/src/index.ts CHANGED
@@ -130,6 +130,20 @@ export type {
130
130
  export { ColorSchemeToggle } from "./ColorSchemeToggle/ColorSchemeToggle.tsx"
131
131
  export type { ComboboxProps } from "./Combobox/Combobox.tsx"
132
132
  export { Combobox } from "./Combobox/Combobox.tsx"
133
+ export type { CommandPaletteProps } from "./CommandPalette/CommandPalette.tsx"
134
+ export { CommandPalette } from "./CommandPalette/CommandPalette.tsx"
135
+ export type {
136
+ CommandPaletteGroup,
137
+ CommandPaletteItem,
138
+ CommandPalettePrefix,
139
+ } from "./CommandPalette/commandPaletteSections.ts"
140
+ export type {
141
+ UseCommandPaletteOptions,
142
+ UseCommandPaletteResult,
143
+ } from "./CommandPalette/useCommandPalette.ts"
144
+ export { useCommandPalette } from "./CommandPalette/useCommandPalette.ts"
145
+ export type { CommandPaletteHotkeysOptions } from "./CommandPalette/useCommandPaletteHotkeys.ts"
146
+ export { useCommandPaletteHotkeys } from "./CommandPalette/useCommandPaletteHotkeys.ts"
133
147
  export type { ComparisonChartProps } from "./ComparisonChart/ComparisonChart.tsx"
134
148
  export { ComparisonChart } from "./ComparisonChart/ComparisonChart.tsx"
135
149
  export type {
@@ -626,7 +626,8 @@ test("the barrel is the only place components are re-exported", async () => {
626
626
  // GroupedList owns rows under group headings; castkit, tally-marks
627
627
  // and whereabouts each drew it by hand. `GroupedListRow.tsx` is its
628
628
  // member and stays out of this count by the `<Name>/<Name>.tsx` rule.
629
- expect(componentNames.length).toBe(78)
629
+ // CommandPalette composes Modal and SearchInput into the launcher.
630
+ expect(componentNames.length).toBe(79)
630
631
 
631
632
  for (const name of componentNames) {
632
633
  expect(barrel).toContain(`export { ${name} }`)