@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.
- package/README.md +9 -0
- package/dist/Accordion/Accordion.d.ts +82 -0
- package/dist/Accordion/Accordion.d.ts.map +1 -0
- package/dist/Accordion/Accordion.js +108 -0
- package/dist/Accordion/Accordion.js.map +1 -0
- package/dist/Accordion/AccordionSection.d.ts +28 -0
- package/dist/Accordion/AccordionSection.d.ts.map +1 -0
- package/dist/Accordion/AccordionSection.js +53 -0
- package/dist/Accordion/AccordionSection.js.map +1 -0
- package/dist/Field/Field.d.ts +69 -0
- package/dist/Field/Field.d.ts.map +1 -0
- package/dist/Field/Field.js +73 -0
- package/dist/Field/Field.js.map +1 -0
- package/dist/FileDropZone/FileDropZone.d.ts +63 -0
- package/dist/FileDropZone/FileDropZone.d.ts.map +1 -0
- package/dist/FileDropZone/FileDropZone.js +122 -0
- package/dist/FileDropZone/FileDropZone.js.map +1 -0
- package/dist/LogViewer/LogViewer.d.ts +88 -0
- package/dist/LogViewer/LogViewer.d.ts.map +1 -0
- package/dist/LogViewer/LogViewer.js +104 -0
- package/dist/LogViewer/LogViewer.js.map +1 -0
- package/dist/Menu/Menu.d.ts +81 -0
- package/dist/Menu/Menu.d.ts.map +1 -0
- package/dist/Menu/Menu.js +199 -0
- package/dist/Menu/Menu.js.map +1 -0
- package/dist/Menu/MenuAction.d.ts +24 -0
- package/dist/Menu/MenuAction.d.ts.map +1 -0
- package/dist/Menu/MenuAction.js +37 -0
- package/dist/Menu/MenuAction.js.map +1 -0
- package/dist/Select/Select.d.ts +87 -0
- package/dist/Select/Select.d.ts.map +1 -0
- package/dist/Select/Select.js +51 -0
- package/dist/Select/Select.js.map +1 -0
- package/dist/SortableTableHeader/SortableTableHeader.d.ts +61 -0
- package/dist/SortableTableHeader/SortableTableHeader.d.ts.map +1 -0
- package/dist/SortableTableHeader/SortableTableHeader.js +56 -0
- package/dist/SortableTableHeader/SortableTableHeader.js.map +1 -0
- package/dist/Toast/Toast.d.ts +54 -0
- package/dist/Toast/Toast.d.ts.map +1 -0
- package/dist/Toast/Toast.js +121 -0
- package/dist/Toast/Toast.js.map +1 -0
- package/dist/Toast/ToastRegion.d.ts +57 -0
- package/dist/Toast/ToastRegion.d.ts.map +1 -0
- package/dist/Toast/ToastRegion.js +41 -0
- package/dist/Toast/ToastRegion.js.map +1 -0
- package/dist/Toast/toastLifecycle.d.ts +34 -0
- package/dist/Toast/toastLifecycle.d.ts.map +1 -0
- package/dist/Toast/toastLifecycle.js +25 -0
- package/dist/Toast/toastLifecycle.js.map +1 -0
- package/dist/Tooltip/Tooltip.d.ts +68 -0
- package/dist/Tooltip/Tooltip.d.ts.map +1 -0
- package/dist/Tooltip/Tooltip.js +104 -0
- package/dist/Tooltip/Tooltip.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/mountStory.testHelpers.d.ts +0 -11
- package/dist/mountStory.testHelpers.d.ts.map +1 -1
- package/dist/mountStory.testHelpers.js +27 -0
- package/dist/mountStory.testHelpers.js.map +1 -1
- package/package.json +3 -3
- package/src/Accordion/Accordion.mdx +83 -0
- package/src/Accordion/Accordion.stories.tsx +146 -0
- package/src/Accordion/Accordion.test.tsx +163 -0
- package/src/Accordion/Accordion.tsx +182 -0
- package/src/Accordion/AccordionSection.tsx +163 -0
- package/src/Field/Field.mdx +74 -0
- package/src/Field/Field.stories.tsx +144 -0
- package/src/Field/Field.test.tsx +142 -0
- package/src/Field/Field.tsx +163 -0
- package/src/FileDropZone/FileDropZone.mdx +78 -0
- package/src/FileDropZone/FileDropZone.stories.tsx +136 -0
- package/src/FileDropZone/FileDropZone.test.tsx +230 -0
- package/src/FileDropZone/FileDropZone.tsx +234 -0
- package/src/LogViewer/LogViewer.mdx +88 -0
- package/src/LogViewer/LogViewer.stories.tsx +135 -0
- package/src/LogViewer/LogViewer.test.tsx +173 -0
- package/src/LogViewer/LogViewer.tsx +195 -0
- package/src/Menu/Menu.mdx +103 -0
- package/src/Menu/Menu.stories.tsx +161 -0
- package/src/Menu/Menu.test.tsx +202 -0
- package/src/Menu/Menu.tsx +300 -0
- package/src/Menu/MenuAction.tsx +95 -0
- package/src/Select/Select.mdx +74 -0
- package/src/Select/Select.stories.tsx +126 -0
- package/src/Select/Select.test.tsx +129 -0
- package/src/Select/Select.tsx +200 -0
- package/src/SortableTableHeader/SortableTableHeader.mdx +67 -0
- package/src/SortableTableHeader/SortableTableHeader.stories.tsx +207 -0
- package/src/SortableTableHeader/SortableTableHeader.test.tsx +180 -0
- package/src/SortableTableHeader/SortableTableHeader.tsx +131 -0
- package/src/Toast/Toast.mdx +84 -0
- package/src/Toast/Toast.stories.tsx +192 -0
- package/src/Toast/Toast.test.tsx +217 -0
- package/src/Toast/Toast.tsx +224 -0
- package/src/Toast/ToastRegion.tsx +98 -0
- package/src/Toast/toastLifecycle.ts +45 -0
- package/src/Tooltip/Tooltip.mdx +69 -0
- package/src/Tooltip/Tooltip.stories.tsx +104 -0
- package/src/Tooltip/Tooltip.test.tsx +138 -0
- package/src/Tooltip/Tooltip.tsx +169 -0
- package/src/index.ts +43 -0
- package/src/mountStory.testHelpers.ts +30 -0
- package/src/sourceRules.test.ts +221 -30
- 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
|