@charcuterie/ui 0.1.0 → 0.2.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 (106) hide show
  1. package/README.md +9 -0
  2. package/dist/Accordion/Accordion.d.ts +82 -0
  3. package/dist/Accordion/Accordion.d.ts.map +1 -0
  4. package/dist/Accordion/Accordion.js +108 -0
  5. package/dist/Accordion/Accordion.js.map +1 -0
  6. package/dist/Accordion/AccordionSection.d.ts +28 -0
  7. package/dist/Accordion/AccordionSection.d.ts.map +1 -0
  8. package/dist/Accordion/AccordionSection.js +53 -0
  9. package/dist/Accordion/AccordionSection.js.map +1 -0
  10. package/dist/Field/Field.d.ts +69 -0
  11. package/dist/Field/Field.d.ts.map +1 -0
  12. package/dist/Field/Field.js +73 -0
  13. package/dist/Field/Field.js.map +1 -0
  14. package/dist/FileDropZone/FileDropZone.d.ts +63 -0
  15. package/dist/FileDropZone/FileDropZone.d.ts.map +1 -0
  16. package/dist/FileDropZone/FileDropZone.js +122 -0
  17. package/dist/FileDropZone/FileDropZone.js.map +1 -0
  18. package/dist/LogViewer/LogViewer.d.ts +88 -0
  19. package/dist/LogViewer/LogViewer.d.ts.map +1 -0
  20. package/dist/LogViewer/LogViewer.js +104 -0
  21. package/dist/LogViewer/LogViewer.js.map +1 -0
  22. package/dist/Menu/Menu.d.ts +81 -0
  23. package/dist/Menu/Menu.d.ts.map +1 -0
  24. package/dist/Menu/Menu.js +199 -0
  25. package/dist/Menu/Menu.js.map +1 -0
  26. package/dist/Menu/MenuAction.d.ts +24 -0
  27. package/dist/Menu/MenuAction.d.ts.map +1 -0
  28. package/dist/Menu/MenuAction.js +37 -0
  29. package/dist/Menu/MenuAction.js.map +1 -0
  30. package/dist/Select/Select.d.ts +87 -0
  31. package/dist/Select/Select.d.ts.map +1 -0
  32. package/dist/Select/Select.js +51 -0
  33. package/dist/Select/Select.js.map +1 -0
  34. package/dist/SortableTableHeader/SortableTableHeader.d.ts +61 -0
  35. package/dist/SortableTableHeader/SortableTableHeader.d.ts.map +1 -0
  36. package/dist/SortableTableHeader/SortableTableHeader.js +56 -0
  37. package/dist/SortableTableHeader/SortableTableHeader.js.map +1 -0
  38. package/dist/Toast/Toast.d.ts +54 -0
  39. package/dist/Toast/Toast.d.ts.map +1 -0
  40. package/dist/Toast/Toast.js +121 -0
  41. package/dist/Toast/Toast.js.map +1 -0
  42. package/dist/Toast/ToastRegion.d.ts +57 -0
  43. package/dist/Toast/ToastRegion.d.ts.map +1 -0
  44. package/dist/Toast/ToastRegion.js +41 -0
  45. package/dist/Toast/ToastRegion.js.map +1 -0
  46. package/dist/Toast/toastLifecycle.d.ts +34 -0
  47. package/dist/Toast/toastLifecycle.d.ts.map +1 -0
  48. package/dist/Toast/toastLifecycle.js +25 -0
  49. package/dist/Toast/toastLifecycle.js.map +1 -0
  50. package/dist/Tooltip/Tooltip.d.ts +68 -0
  51. package/dist/Tooltip/Tooltip.d.ts.map +1 -0
  52. package/dist/Tooltip/Tooltip.js +104 -0
  53. package/dist/Tooltip/Tooltip.js.map +1 -0
  54. package/dist/index.d.ts +22 -0
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +11 -0
  57. package/dist/index.js.map +1 -1
  58. package/dist/mountStory.testHelpers.d.ts +0 -11
  59. package/dist/mountStory.testHelpers.d.ts.map +1 -1
  60. package/dist/mountStory.testHelpers.js +27 -0
  61. package/dist/mountStory.testHelpers.js.map +1 -1
  62. package/package.json +3 -3
  63. package/src/Accordion/Accordion.mdx +83 -0
  64. package/src/Accordion/Accordion.stories.tsx +146 -0
  65. package/src/Accordion/Accordion.test.tsx +163 -0
  66. package/src/Accordion/Accordion.tsx +182 -0
  67. package/src/Accordion/AccordionSection.tsx +163 -0
  68. package/src/Field/Field.mdx +74 -0
  69. package/src/Field/Field.stories.tsx +144 -0
  70. package/src/Field/Field.test.tsx +142 -0
  71. package/src/Field/Field.tsx +163 -0
  72. package/src/FileDropZone/FileDropZone.mdx +78 -0
  73. package/src/FileDropZone/FileDropZone.stories.tsx +136 -0
  74. package/src/FileDropZone/FileDropZone.test.tsx +230 -0
  75. package/src/FileDropZone/FileDropZone.tsx +234 -0
  76. package/src/LogViewer/LogViewer.mdx +88 -0
  77. package/src/LogViewer/LogViewer.stories.tsx +135 -0
  78. package/src/LogViewer/LogViewer.test.tsx +173 -0
  79. package/src/LogViewer/LogViewer.tsx +195 -0
  80. package/src/Menu/Menu.mdx +103 -0
  81. package/src/Menu/Menu.stories.tsx +161 -0
  82. package/src/Menu/Menu.test.tsx +202 -0
  83. package/src/Menu/Menu.tsx +300 -0
  84. package/src/Menu/MenuAction.tsx +95 -0
  85. package/src/Select/Select.mdx +74 -0
  86. package/src/Select/Select.stories.tsx +126 -0
  87. package/src/Select/Select.test.tsx +129 -0
  88. package/src/Select/Select.tsx +200 -0
  89. package/src/SortableTableHeader/SortableTableHeader.mdx +67 -0
  90. package/src/SortableTableHeader/SortableTableHeader.stories.tsx +207 -0
  91. package/src/SortableTableHeader/SortableTableHeader.test.tsx +180 -0
  92. package/src/SortableTableHeader/SortableTableHeader.tsx +131 -0
  93. package/src/Toast/Toast.mdx +84 -0
  94. package/src/Toast/Toast.stories.tsx +192 -0
  95. package/src/Toast/Toast.test.tsx +217 -0
  96. package/src/Toast/Toast.tsx +224 -0
  97. package/src/Toast/ToastRegion.tsx +98 -0
  98. package/src/Toast/toastLifecycle.ts +45 -0
  99. package/src/Tooltip/Tooltip.mdx +69 -0
  100. package/src/Tooltip/Tooltip.stories.tsx +104 -0
  101. package/src/Tooltip/Tooltip.test.tsx +138 -0
  102. package/src/Tooltip/Tooltip.tsx +169 -0
  103. package/src/index.ts +43 -0
  104. package/src/mountStory.testHelpers.ts +30 -0
  105. package/src/sourceRules.test.ts +221 -30
  106. package/CHANGELOG.md +0 -13
@@ -0,0 +1,122 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useUniqueId } from "@charcuterie/logic";
3
+ import { useRef, useState } from "react";
4
+ import { toClassName } from "../toClassName.js";
5
+ /**
6
+ * The one drag target in the fleet is gallery-downloader's, and it
7
+ * gets the hard part right: `dragenter`/`dragleave` fire once per
8
+ * *element* the pointer crosses, not once per zone, so a naive
9
+ * implementation flickers over every child — and that page keeps a
10
+ * `dragDepth` counter, which is the correct fix. It is reproduced
11
+ * below with credit.
12
+ *
13
+ * What it has no answer for is the part that cannot be fixed by
14
+ * counting:
15
+ *
16
+ * ### Dragging is pointer-only, so the zone is a label around an input
17
+ *
18
+ * There is **no keyboard gesture for drag-and-drop**. WCAG 2.5.7
19
+ * (AA since 2.2) requires a single-pointer alternative for any
20
+ * dragging movement, and the honest one has existed since 1995: a
21
+ * real `<input type="file">`. So this component is a `<label>`
22
+ * wrapping a visually-hidden input — the whole zone is the label's
23
+ * click target, Tab reaches the input, Space and Enter open the
24
+ * picker, and the drop handlers are an *enhancement* on top of a
25
+ * control that already works without them.
26
+ *
27
+ * That ordering is the entire design. Building the drop zone first
28
+ * and bolting on a "or browse" link is how the fleet's version ended
29
+ * up with an `alert()` as its error channel and no keyboard path at
30
+ * all.
31
+ *
32
+ * ### `dragover` must be prevented, and the reason is a footgun
33
+ *
34
+ * A browser's default for `dragover` is *reject the drop*. Not
35
+ * preventing it means `drop` never fires — nothing errors, the
36
+ * cursor shows a "no entry" sign, and the zone looks like it has a
37
+ * broken handler when it in fact has no handler running at all.
38
+ *
39
+ * ### It reports text as well as files
40
+ *
41
+ * Dragging a link gives you `text/plain` and an empty `files` list.
42
+ * A zone that only reads `.files` silently ignores the single most
43
+ * common thing a user drags into a downloader, which is why
44
+ * `onDropText` is here rather than being someone's follow-up.
45
+ */
46
+ export const FileDropZone = ({ accept, className, description, isDisabled = false, isMultiple = false, label, onDropFiles, onDropText, }) => {
47
+ const baseId = useUniqueId();
48
+ const inputId = `${baseId}-input`;
49
+ const labelId = `${baseId}-label`;
50
+ const descriptionId = `${baseId}-description`;
51
+ const [isDragActive, setIsDragActive] = useState(false);
52
+ /**
53
+ * The counter gallery-downloader's page already keeps, and the
54
+ * reason it has to exist: `dragenter` fires again for every child
55
+ * element the pointer crosses, and `dragleave` fires for the one
56
+ * it left — so a plain boolean turns off halfway across the zone
57
+ * and the highlight strobes.
58
+ *
59
+ * A ref rather than state: nothing renders from the depth itself,
60
+ * only from whether it is above zero, and a re-render per crossed
61
+ * child during a drag is exactly the jank this is avoiding.
62
+ */
63
+ const dragDepth = useRef(0);
64
+ const endDrag = () => {
65
+ dragDepth.current = 0;
66
+ setIsDragActive(false);
67
+ };
68
+ const handleDrop = (dropEvent) => {
69
+ dropEvent.preventDefault();
70
+ endDrag();
71
+ if (isDisabled) {
72
+ return;
73
+ }
74
+ const files = Array.from(dropEvent.dataTransfer.files);
75
+ if (files.length > 0) {
76
+ onDropFiles(isMultiple ? files : files.slice(0, 1));
77
+ return;
78
+ }
79
+ const text = dropEvent.dataTransfer.getData("text");
80
+ if (text !== "") {
81
+ onDropText?.(text);
82
+ }
83
+ };
84
+ return (_jsxs("label", { className: toClassName("flex cursor-pointer flex-col items-center gap-1 rounded-md border-2 border-border-default border-dashed p-6 text-center transition-colors duration-(--duration-fast) ease-standard", isDragActive
85
+ ? "border-intent-accent-border bg-intent-accent-surface"
86
+ : "bg-surface-sunken hover:border-border-strong", isDisabled &&
87
+ "cursor-not-allowed border-border-subtle text-content-disabled hover:border-border-subtle",
88
+ // The ring is on the label, driven by the input inside it —
89
+ // the input is what actually takes focus, and it is
90
+ // visually hidden, so a ring on the input paints nothing.
91
+ "focus-within:outline-solid focus-within:outline-(length:--focus-ring-width) focus-within:outline-offset-(--focus-ring-offset) focus-within:outline-focus-ring", className), htmlFor: inputId, onDragEnter: (dragEvent) => {
92
+ dragEvent.preventDefault();
93
+ dragDepth.current += 1;
94
+ if (dragDepth.current === 1) {
95
+ setIsDragActive(true);
96
+ }
97
+ }, onDragLeave: (dragEvent) => {
98
+ dragEvent.preventDefault();
99
+ dragDepth.current -= 1;
100
+ if (dragDepth.current === 0) {
101
+ setIsDragActive(false);
102
+ }
103
+ }, onDragOver: (dragEvent) => {
104
+ // Not optional. The default action for `dragover` is to
105
+ // reject the drop, so without this `onDrop` below never runs
106
+ // and the zone looks broken rather than unhandled.
107
+ dragEvent.preventDefault();
108
+ }, onDrop: handleDrop, children: [_jsx("input", { accept: accept, "aria-describedby": description === undefined
109
+ ? undefined
110
+ : descriptionId, "aria-labelledby": labelId, className: "sr-only", disabled: isDisabled, id: inputId, multiple: isMultiple, onChange: (changeEvent) => {
111
+ const files = Array.from(changeEvent.currentTarget.files ?? []);
112
+ if (files.length > 0) {
113
+ onDropFiles(files);
114
+ }
115
+ // Cleared so choosing the *same* file twice fires again.
116
+ // A file input keeps its value, so the second pick is not
117
+ // a change and the handler never runs — which reads as
118
+ // "it ignored me" and is the oldest bug in this control.
119
+ changeEvent.currentTarget.value = "";
120
+ }, type: "file" }), _jsx("span", { className: "font-medium text-content-primary text-sm", id: labelId, children: label }), description === undefined ? null : (_jsx("span", { className: "text-content-secondary text-xs", id: descriptionId, children: description }))] }));
121
+ };
122
+ //# sourceMappingURL=FileDropZone.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FileDropZone.js","sourceRoot":"","sources":["../../src/FileDropZone/FileDropZone.tsx"],"names":[],"mappings":"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"}
@@ -0,0 +1,88 @@
1
+ import type { IntentName } from "@charcuterie/tokens";
2
+ import type { ReactNode } from "react";
3
+ export type LogLine = {
4
+ intent?: IntentName;
5
+ key: string;
6
+ text: string;
7
+ };
8
+ export type LogViewerProps = {
9
+ className?: string;
10
+ /**
11
+ * Announce new lines to a screen reader. **Off by default**, and
12
+ * that is a considered choice rather than an oversight — see
13
+ * below.
14
+ */
15
+ isAnnounced?: boolean;
16
+ /** The pane's accessible name. Required. */
17
+ label: string;
18
+ lines: LogLine[];
19
+ /**
20
+ * Oldest lines past this are dropped from the DOM. A rip in
21
+ * progress emits a line every few hundred milliseconds for an
22
+ * hour, and the browser keeps every node.
23
+ */
24
+ maxLines?: number;
25
+ };
26
+ /**
27
+ * Three repos render a scrolling log pane —
28
+ * mux-magic's `JobLogsDisclosure` and `StepLogs`, rip-deck's
29
+ * `LogModal` — and between them they have four defects that this
30
+ * component exists to not have.
31
+ *
32
+ * ### 1. The one that follows nothing
33
+ *
34
+ * ```tsx
35
+ * useEffect(() => {
36
+ * const pane = paneRef.current
37
+ * if (pane) pane.scrollTop = pane.scrollHeight
38
+ * }, []) // ← empty deps
39
+ * ```
40
+ *
41
+ * That is mux-magic's auto-scroll, and it runs **once**, on mount,
42
+ * when the pane is empty and `scrollHeight` is `clientHeight`. Every
43
+ * line after it arrives below the fold. It reads as correct, it is
44
+ * the shape everyone writes, and the log simply never follows.
45
+ *
46
+ * ### 2. Following that cannot be escaped
47
+ *
48
+ * The naive fix — scroll to the bottom on every render — is worse:
49
+ * a user scrolling back to read the line that mentioned an error is
50
+ * yanked to the end twice a second. Following has to be *pinned to
51
+ * the user's own position*, which is what `isFollowing` below is: it
52
+ * turns off the moment the user scrolls away and back on when they
53
+ * return to the bottom, and there is a button for when they do not
54
+ * want to scroll all that way.
55
+ *
56
+ * ### 3. `data-log-id`
57
+ *
58
+ * mux-magic's pane carries one. It is a `data-testid` under a
59
+ * different name — a handle only the test suite can see — and this
60
+ * package's `sourceRules.test.ts` bans the practice rather than the
61
+ * spelling. The pane here is found by its `label`, the same query
62
+ * Playwright makes.
63
+ *
64
+ * ### 4. Announcing an hour of build output
65
+ *
66
+ * `role="log"` is the correct role and `aria-live` is off unless
67
+ * asked for. A live log pane emitting a line every 300 ms is a
68
+ * screen reader that cannot be interrupted and a user who cannot
69
+ * read anything else on the page — the accessible answer for a
70
+ * long-running job is a `LiveStatusIndicator` announcing *state*
71
+ * and a log that is there to be read when wanted. `isAnnounced`
72
+ * exists for the short case: a five-line result panel where the
73
+ * lines *are* the outcome.
74
+ *
75
+ * ### Not every boolean is a state kind
76
+ *
77
+ * `isFollowing` is `useState`, not `useVisibility`, and this is the
78
+ * first component in the library to say so out loud. The kinds in
79
+ * `@charcuterie/logic` earn their keep when state is **registered**
80
+ * (members join a group), **composed** (focus and selection
81
+ * disagreeing), or **shared** (a trigger and a panel at opposite
82
+ * ends of a tree). None of that is true here: one component reads
83
+ * one boolean it wrote itself. Reaching for `useVisibility` because
84
+ * it is also a boolean would make `isVisible` mean "is following",
85
+ * which is how a state layer stops meaning anything.
86
+ */
87
+ export declare const LogViewer: ({ className, isAnnounced, label, lines, maxLines, }: LogViewerProps) => ReactNode;
88
+ //# sourceMappingURL=LogViewer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"LogViewer.d.ts","sourceRoot":"","sources":["../../src/LogViewer/LogViewer.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAA;AACrD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAOtC,MAAM,MAAM,OAAO,GAAG;IACpB,MAAM,CAAC,EAAE,UAAU,CAAA;IACnB,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,MAAM,CAAA;CACb,CAAA;AAED,MAAM,MAAM,cAAc,GAAG;IAC3B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;OAIG;IACH,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,4CAA4C;IAC5C,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,OAAO,EAAE,CAAA;IAChB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4DG;AACH,eAAO,MAAM,SAAS,GAAI,qDAMvB,cAAc,KAAG,SA8FnB,CAAA"}
@@ -0,0 +1,104 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { Button } from "../Button/Button.js";
4
+ import { INTENT_CONTENT_CLASS } from "../intentStyles.js";
5
+ import { toClassName } from "../toClassName.js";
6
+ /**
7
+ * Three repos render a scrolling log pane —
8
+ * mux-magic's `JobLogsDisclosure` and `StepLogs`, rip-deck's
9
+ * `LogModal` — and between them they have four defects that this
10
+ * component exists to not have.
11
+ *
12
+ * ### 1. The one that follows nothing
13
+ *
14
+ * ```tsx
15
+ * useEffect(() => {
16
+ * const pane = paneRef.current
17
+ * if (pane) pane.scrollTop = pane.scrollHeight
18
+ * }, []) // ← empty deps
19
+ * ```
20
+ *
21
+ * That is mux-magic's auto-scroll, and it runs **once**, on mount,
22
+ * when the pane is empty and `scrollHeight` is `clientHeight`. Every
23
+ * line after it arrives below the fold. It reads as correct, it is
24
+ * the shape everyone writes, and the log simply never follows.
25
+ *
26
+ * ### 2. Following that cannot be escaped
27
+ *
28
+ * The naive fix — scroll to the bottom on every render — is worse:
29
+ * a user scrolling back to read the line that mentioned an error is
30
+ * yanked to the end twice a second. Following has to be *pinned to
31
+ * the user's own position*, which is what `isFollowing` below is: it
32
+ * turns off the moment the user scrolls away and back on when they
33
+ * return to the bottom, and there is a button for when they do not
34
+ * want to scroll all that way.
35
+ *
36
+ * ### 3. `data-log-id`
37
+ *
38
+ * mux-magic's pane carries one. It is a `data-testid` under a
39
+ * different name — a handle only the test suite can see — and this
40
+ * package's `sourceRules.test.ts` bans the practice rather than the
41
+ * spelling. The pane here is found by its `label`, the same query
42
+ * Playwright makes.
43
+ *
44
+ * ### 4. Announcing an hour of build output
45
+ *
46
+ * `role="log"` is the correct role and `aria-live` is off unless
47
+ * asked for. A live log pane emitting a line every 300 ms is a
48
+ * screen reader that cannot be interrupted and a user who cannot
49
+ * read anything else on the page — the accessible answer for a
50
+ * long-running job is a `LiveStatusIndicator` announcing *state*
51
+ * and a log that is there to be read when wanted. `isAnnounced`
52
+ * exists for the short case: a five-line result panel where the
53
+ * lines *are* the outcome.
54
+ *
55
+ * ### Not every boolean is a state kind
56
+ *
57
+ * `isFollowing` is `useState`, not `useVisibility`, and this is the
58
+ * first component in the library to say so out loud. The kinds in
59
+ * `@charcuterie/logic` earn their keep when state is **registered**
60
+ * (members join a group), **composed** (focus and selection
61
+ * disagreeing), or **shared** (a trigger and a panel at opposite
62
+ * ends of a tree). None of that is true here: one component reads
63
+ * one boolean it wrote itself. Reaching for `useVisibility` because
64
+ * it is also a boolean would make `isVisible` mean "is following",
65
+ * which is how a state layer stops meaning anything.
66
+ */
67
+ export const LogViewer = ({ className, isAnnounced = false, label, lines, maxLines = 500, }) => {
68
+ const paneRef = useRef(null);
69
+ const [isFollowing, setIsFollowing] = useState(true);
70
+ const shownLines = lines.length > maxLines
71
+ ? lines.slice(lines.length - maxLines)
72
+ : lines;
73
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `shownLines` is read through the DOM, not through the closure — `pane.scrollHeight` is a *consequence* of the lines having rendered, so the linter sees a dependency it cannot trace and calls it unnecessary. Removing it is not a style change: the effect then runs only when `isFollowing` flips, which is mux-magic's `}, [])` bug rebuilt exactly. `yarn lint` runs `--write --unsafe`, so this was applied silently once already and the log stopped following.
74
+ useEffect(() => {
75
+ const pane = paneRef.current;
76
+ if (!pane || !isFollowing) {
77
+ return;
78
+ }
79
+ pane.scrollTop = pane.scrollHeight;
80
+ // `shownLines` rather than an empty array — this is the whole fix
81
+ // for defect 1, and the dependency *is* the fix.
82
+ }, [isFollowing, shownLines]);
83
+ return (_jsxs("div", { className: toClassName("relative flex flex-col", className), children: [_jsx("div", { "aria-label": label, "aria-live": isAnnounced ? "polite" : "off", className: "max-h-64 overflow-y-auto rounded-md border border-border-subtle bg-surface-sunken p-2 font-mono text-content-secondary text-xs", onScroll: (scrollEvent) => {
84
+ const pane = scrollEvent.currentTarget;
85
+ // A few pixels of slack, because a fractional device pixel
86
+ // ratio means `scrollTop + clientHeight` never lands
87
+ // exactly on `scrollHeight` — an equality test here
88
+ // un-pins the pane on a 125% display and on nothing else,
89
+ // which is the kind of bug that gets reported as "it works
90
+ // on my machine".
91
+ const isPinnedToBottom = pane.scrollHeight -
92
+ pane.scrollTop -
93
+ pane.clientHeight <
94
+ 4;
95
+ setIsFollowing(isPinnedToBottom);
96
+ }, ref: paneRef, role: "log",
97
+ // biome-ignore lint/a11y/noNoninteractiveTabindex: a scroll container must be focusable or a keyboard user can see the pane and cannot scroll it — axe's `scrollable-region-focusable`, which fails this component without the line. Chrome started doing it automatically in 2024; Firefox and Safari have not. The rule is right about `<div>`s in general and wrong about scrollers.
98
+ tabIndex: 0, children: shownLines.length === 0 ? (_jsx("p", { className: "text-content-muted", children: "Waiting for output\u2026" })) : (shownLines.map((line) => (_jsx("p", { className: toClassName("whitespace-pre-wrap break-all", line.intent === undefined
99
+ ? null
100
+ : INTENT_CONTENT_CLASS[line.intent]), children: line.text }, line.key)))) }), isFollowing ? null : (_jsx(Button, { appearance: "soft", className: "absolute inset-x-0 bottom-2 mx-auto w-fit", intent: "neutral", onClick: () => {
101
+ setIsFollowing(true);
102
+ }, size: "sm", children: "Jump to latest" }))] }));
103
+ };
104
+ //# sourceMappingURL=LogViewer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"LogViewer.js","sourceRoot":"","sources":["../../src/LogViewer/LogViewer.tsx"],"names":[],"mappings":"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"}
@@ -0,0 +1,81 @@
1
+ import type { Placement } from "@floating-ui/react";
2
+ import type { ReactElement, ReactNode } from "react";
3
+ export type MenuItem = {
4
+ icon?: ReactNode;
5
+ isDisabled?: boolean;
6
+ key: string;
7
+ label: ReactNode;
8
+ onSelect: () => void;
9
+ };
10
+ export type MenuProps = {
11
+ className?: string;
12
+ isVisible: boolean;
13
+ items: MenuItem[];
14
+ /** Outside press, Escape, and choosing an item all land here. */
15
+ onDismiss: () => void;
16
+ placement?: Placement;
17
+ /** The control the menu hangs off. **Cloned, not wrapped.** */
18
+ trigger: ReactElement;
19
+ };
20
+ /**
21
+ * A menu is **not** a listbox, and mux-magic's `TypePicker` is the
22
+ * fleet's one attempt at telling them apart — it renders
23
+ * `role="menu"` over items that set a value, which is a listbox
24
+ * wearing the wrong role. The distinction is what the items *are*:
25
+ *
26
+ * - `menuitem` **does** something. Rename, Delete, Copy path.
27
+ * - `option` **is** something. It is a value you are choosing.
28
+ *
29
+ * A screen reader announces "menu, 4 items" for one and "listbox,
30
+ * selected, 2 of 4" for the other, and an agent driving the app
31
+ * needs the second to know what state it left behind. Getting it
32
+ * wrong is invisible in a browser and invisible to axe.
33
+ *
34
+ * ### There is no `label` prop, and that corrects a lesson
35
+ *
36
+ * `Spinner` (M3), `Popover` and `Modal` (M4) all take a **required**
37
+ * name, because a `role="status"` and a `role="dialog"` take none
38
+ * from their content. The obvious move here was a `label` prop doing
39
+ * the same job, and it was written that way first — then measured
40
+ * as doing nothing.
41
+ *
42
+ * `useRole(context, { role: "menu" })` puts **`aria-labelledby` on
43
+ * the panel pointing at the trigger**, and `aria-labelledby` beats
44
+ * `aria-label`. So the menu was already named — "Bay 3", from the
45
+ * button — and the prop was silently discarded. That is also the ARIA
46
+ * Authoring Practices menu-button pattern, where a menu is named by
47
+ * the control that opens it.
48
+ *
49
+ * The rule the other three teach is therefore narrower than it
50
+ * looked: **an overlay with no trigger relationship needs its own
51
+ * name.** A menu has one, so the requirement moves to the trigger —
52
+ * which `Button` and `IconButton` already enforce.
53
+ *
54
+ * ### Roving focus, and the composition `Tabs` already proved
55
+ *
56
+ * `RovingFocus` alone this time — there is no selection, because a
57
+ * menu item is not selected, it is invoked. That is the same
58
+ * two-kinds argument from the other side: `Tabs` needed both kinds
59
+ * because a tab is chosen *and* focused, and a menu needs one
60
+ * because a menu item is only ever focused.
61
+ *
62
+ * ### Focus moves in, once, and only on opening
63
+ *
64
+ * A menu is one of the few patterns where focus must jump on open —
65
+ * otherwise the keyboard user who pressed Enter on the trigger is
66
+ * still on the trigger with a menu they cannot reach. It happens in
67
+ * the panel's own effect rather than through
68
+ * `FloatingFocusManager`'s `initialFocus`, because the element to
69
+ * focus is whichever one `RovingFocus` says is active, and that is
70
+ * not known until the items have registered.
71
+ *
72
+ * ### No type-ahead, deliberately
73
+ *
74
+ * The APG lists type-ahead as optional for a menu and required for a
75
+ * listbox, and none of the fleet's menus are long enough to need it
76
+ * — the largest has six items. Adding it would mean a keystroke
77
+ * buffer and a timer in a component that currently has neither, for
78
+ * a menu you can cross in three arrow presses.
79
+ */
80
+ export declare const Menu: ({ className, isVisible, items, onDismiss, placement, trigger, }: MenuProps) => ReactNode;
81
+ //# sourceMappingURL=Menu.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Menu.d.ts","sourceRoot":"","sources":["../../src/Menu/Menu.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAA;AAYnD,OAAO,KAAK,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAMpD,MAAM,MAAM,QAAQ,GAAG;IACrB,IAAI,CAAC,EAAE,SAAS,CAAA;IAChB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,SAAS,CAAA;IAChB,QAAQ,EAAE,MAAM,IAAI,CAAA;CACrB,CAAA;AAED,MAAM,MAAM,SAAS,GAAG;IACtB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,SAAS,EAAE,OAAO,CAAA;IAClB,KAAK,EAAE,QAAQ,EAAE,CAAA;IACjB,iEAAiE;IACjE,SAAS,EAAE,MAAM,IAAI,CAAA;IACrB,SAAS,CAAC,EAAE,SAAS,CAAA;IACrB,+DAA+D;IAC/D,OAAO,EAAE,YAAY,CAAA;CACtB,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2DG;AACH,eAAO,MAAM,IAAI,GAAI,iEAOlB,SAAS,KAAG,SA8Ld,CAAA"}
@@ -0,0 +1,199 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { selectTabIndex, useClonedChild, useRovingFocus, } from "@charcuterie/logic";
3
+ import { autoUpdate, FloatingFocusManager, flip, offset, shift, useDismiss, useFloating, useInteractions, useRole, } from "@floating-ui/react";
4
+ import { useEffect, useRef } from "react";
5
+ import { toClassName } from "../toClassName.js";
6
+ import { MenuAction } from "./MenuAction.js";
7
+ /**
8
+ * A menu is **not** a listbox, and mux-magic's `TypePicker` is the
9
+ * fleet's one attempt at telling them apart — it renders
10
+ * `role="menu"` over items that set a value, which is a listbox
11
+ * wearing the wrong role. The distinction is what the items *are*:
12
+ *
13
+ * - `menuitem` **does** something. Rename, Delete, Copy path.
14
+ * - `option` **is** something. It is a value you are choosing.
15
+ *
16
+ * A screen reader announces "menu, 4 items" for one and "listbox,
17
+ * selected, 2 of 4" for the other, and an agent driving the app
18
+ * needs the second to know what state it left behind. Getting it
19
+ * wrong is invisible in a browser and invisible to axe.
20
+ *
21
+ * ### There is no `label` prop, and that corrects a lesson
22
+ *
23
+ * `Spinner` (M3), `Popover` and `Modal` (M4) all take a **required**
24
+ * name, because a `role="status"` and a `role="dialog"` take none
25
+ * from their content. The obvious move here was a `label` prop doing
26
+ * the same job, and it was written that way first — then measured
27
+ * as doing nothing.
28
+ *
29
+ * `useRole(context, { role: "menu" })` puts **`aria-labelledby` on
30
+ * the panel pointing at the trigger**, and `aria-labelledby` beats
31
+ * `aria-label`. So the menu was already named — "Bay 3", from the
32
+ * button — and the prop was silently discarded. That is also the ARIA
33
+ * Authoring Practices menu-button pattern, where a menu is named by
34
+ * the control that opens it.
35
+ *
36
+ * The rule the other three teach is therefore narrower than it
37
+ * looked: **an overlay with no trigger relationship needs its own
38
+ * name.** A menu has one, so the requirement moves to the trigger —
39
+ * which `Button` and `IconButton` already enforce.
40
+ *
41
+ * ### Roving focus, and the composition `Tabs` already proved
42
+ *
43
+ * `RovingFocus` alone this time — there is no selection, because a
44
+ * menu item is not selected, it is invoked. That is the same
45
+ * two-kinds argument from the other side: `Tabs` needed both kinds
46
+ * because a tab is chosen *and* focused, and a menu needs one
47
+ * because a menu item is only ever focused.
48
+ *
49
+ * ### Focus moves in, once, and only on opening
50
+ *
51
+ * A menu is one of the few patterns where focus must jump on open —
52
+ * otherwise the keyboard user who pressed Enter on the trigger is
53
+ * still on the trigger with a menu they cannot reach. It happens in
54
+ * the panel's own effect rather than through
55
+ * `FloatingFocusManager`'s `initialFocus`, because the element to
56
+ * focus is whichever one `RovingFocus` says is active, and that is
57
+ * not known until the items have registered.
58
+ *
59
+ * ### No type-ahead, deliberately
60
+ *
61
+ * The APG lists type-ahead as optional for a menu and required for a
62
+ * listbox, and none of the fleet's menus are long enough to need it
63
+ * — the largest has six items. Adding it would mean a keystroke
64
+ * buffer and a timer in a component that currently has neither, for
65
+ * a menu you can cross in three arrow presses.
66
+ */
67
+ export const Menu = ({ className, isVisible, items, onDismiss, placement = "bottom-start", trigger, }) => {
68
+ const itemElements = useRef(new Map());
69
+ const panelElement = useRef(null);
70
+ const focus = useRovingFocus();
71
+ const { activeValue, registeredValues, setActiveValue } = focus;
72
+ const [firstValue] = registeredValues;
73
+ const { context, floatingStyles, refs } = useFloating({
74
+ open: isVisible,
75
+ onOpenChange: (isNextVisible) => {
76
+ if (!isNextVisible) {
77
+ onDismiss();
78
+ }
79
+ },
80
+ placement,
81
+ middleware: [offset(4), flip(), shift({ padding: 8 })],
82
+ strategy: "fixed",
83
+ whileElementsMounted: autoUpdate,
84
+ });
85
+ const { getFloatingProps, getReferenceProps } = useInteractions([
86
+ useDismiss(context),
87
+ useRole(context, { role: "menu" }),
88
+ ]);
89
+ const clonedTrigger = useClonedChild(trigger, {
90
+ ...getReferenceProps(),
91
+ ref: refs.setReference,
92
+ });
93
+ /**
94
+ * The other half of the `isConnected` guard below. If the ref
95
+ * fired while the node was still detached, nothing has shown the
96
+ * popover — and a `[popover]` that was never shown is
97
+ * `display: none`, so the menu renders, passes typecheck, and is
98
+ * invisible to `getByRole` and to a user alike.
99
+ *
100
+ * Effects run after the tree is connected, so this is the
101
+ * backstop. It is idempotent: `:popover-open` is the same question
102
+ * the ref asks.
103
+ */
104
+ useEffect(() => {
105
+ const panel = panelElement.current;
106
+ if (isVisible &&
107
+ panel?.isConnected &&
108
+ !panel.matches(":popover-open")) {
109
+ panel.showPopover();
110
+ }
111
+ }, [isVisible]);
112
+ /**
113
+ * Opening **makes** a member active, and this is the step the
114
+ * first version was missing.
115
+ *
116
+ * `RovingFocus` leaves `activeValue` at `null` until something
117
+ * sets it — `selectTabIndex` covers the first paint by giving the
118
+ * tab stop to `registeredValues[0]` while it is still null, which
119
+ * is exactly why nothing looked wrong: the menu had a correct tab
120
+ * stop, correct roles, and no focus. `Tabs` never hit it because a
121
+ * tab bar seeds itself from its selected key.
122
+ *
123
+ * Re-running on every open is deliberate. The APG says a menu
124
+ * opens on its first item, so a menu reopened after arrowing to
125
+ * the third one starts at the top again rather than resuming a
126
+ * position the user has forgotten.
127
+ */
128
+ useEffect(() => {
129
+ if (!isVisible || firstValue === undefined) {
130
+ return;
131
+ }
132
+ setActiveValue(firstValue);
133
+ }, [firstValue, isVisible, setActiveValue]);
134
+ useEffect(() => {
135
+ if (!isVisible || activeValue === null) {
136
+ return;
137
+ }
138
+ itemElements.current.get(activeValue)?.focus();
139
+ }, [activeValue, isVisible]);
140
+ return (_jsxs(_Fragment, { children: [clonedTrigger, isVisible ? (_jsx(FloatingFocusManager, { context: context,
141
+ // `false`, so Tab leaves the menu and lands on the next
142
+ // control in the page — which is what closes it, through
143
+ // `useDismiss`. A trapped menu is a dialog.
144
+ modal: false,
145
+ // Focus is moved by the effect above, to whichever item
146
+ // `RovingFocus` made active. Letting the manager also aim
147
+ // it is two owners for one caret.
148
+ initialFocus: -1, children: _jsx("div", { ...getFloatingProps(), className: toClassName("inset-auto m-0 flex min-w-48 flex-col gap-0.5 rounded-md border border-border-default bg-surface-overlay p-1 shadow-medium", className), onKeyDown: (keyEvent) => {
149
+ const commands = {
150
+ ArrowDown: focus.next,
151
+ ArrowUp: focus.previous,
152
+ End: focus.last,
153
+ Home: focus.first,
154
+ };
155
+ const command = commands[keyEvent.key];
156
+ if (command) {
157
+ // Arrow keys scroll the page by default, and a menu
158
+ // that scrolls the document behind it while moving
159
+ // focus is the hand-rolled version of this
160
+ // component.
161
+ keyEvent.preventDefault();
162
+ command();
163
+ }
164
+ }, popover: "manual", ref: (node) => {
165
+ refs.setFloating(node);
166
+ panelElement.current = node;
167
+ // A ref callback rather than an effect, for the same
168
+ // reason as `Popover`: a `[popover]` is
169
+ // `display: none` until shown, and every child effect
170
+ // — including the focus move above — would otherwise
171
+ // be aiming at a hidden element.
172
+ //
173
+ // `isConnected` is the guard `Popover` does not need
174
+ // and this does. `FloatingFocusManager` renders a pair
175
+ // of focus guards around its child, so React commits
176
+ // this subtree in an order where the ref can fire
177
+ // while the node is still detached — and
178
+ // `showPopover()` on a disconnected element throws
179
+ // `InvalidStateError`, which surfaces as an unhandled
180
+ // rejection rather than as a failing assertion.
181
+ if (node?.isConnected &&
182
+ !node.matches(":popover-open")) {
183
+ node.showPopover();
184
+ }
185
+ },
186
+ // Duplicated from `getFloatingProps()` on purpose, and
187
+ // the two cannot differ — `useRole(context, { role:
188
+ // "menu" })` above is where it comes from. Stated here
189
+ // because a linter cannot see a role through a spread.
190
+ role: "menu", style: floatingStyles, children: items.map((item) => (_jsx(MenuAction, { item: item, onDismiss: onDismiss, register: focus.register, tabIndex: selectTabIndex(focus, item.key), trackElement: (key, element) => {
191
+ if (element) {
192
+ itemElements.current.set(key, element);
193
+ }
194
+ else {
195
+ itemElements.current.delete(key);
196
+ }
197
+ } }, item.key))) }) })) : null] }));
198
+ };
199
+ //# sourceMappingURL=Menu.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Menu.js","sourceRoot":"","sources":["../../src/Menu/Menu.tsx"],"names":[],"mappings":"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"}
@@ -0,0 +1,24 @@
1
+ import type { ReactNode } from "react";
2
+ import type { MenuItem } from "./Menu.tsx";
3
+ export type MenuActionProps = {
4
+ item: MenuItem;
5
+ onDismiss: () => void;
6
+ /** `RovingFocus.register` — membership of the arrow-key group. */
7
+ register: (value: string) => () => void;
8
+ tabIndex: number;
9
+ trackElement: (key: string, element: HTMLButtonElement | null) => void;
10
+ };
11
+ /**
12
+ * Its own file because **registration is an effect**, and an effect
13
+ * cannot run in a loop — the same reason `TabTrigger` and
14
+ * `AccordionSection` are separate. Any member of a registering group
15
+ * ends up shaped like this.
16
+ *
17
+ * A disabled item is simply not registered, so the arrow keys skip
18
+ * it without any command in `RovingFocus` knowing what "disabled"
19
+ * means. It stays in the DOM and stays announced, which is the
20
+ * difference between "you cannot do this right now" and "this does
21
+ * not exist".
22
+ */
23
+ export declare const MenuAction: ({ item, onDismiss, register, tabIndex, trackElement, }: MenuActionProps) => ReactNode;
24
+ //# sourceMappingURL=MenuAction.d.ts.map