@charcuterie/ui 0.1.1 → 1.0.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 +104 -0
- package/dist/Field/Field.d.ts.map +1 -0
- package/dist/Field/Field.js +115 -0
- package/dist/Field/Field.js.map +1 -0
- package/dist/Field/FieldGroup.d.ts +58 -0
- package/dist/Field/FieldGroup.d.ts.map +1 -0
- package/dist/Field/FieldGroup.js +65 -0
- package/dist/Field/FieldGroup.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 +90 -0
- package/dist/LogViewer/LogViewer.d.ts.map +1 -0
- package/dist/LogViewer/LogViewer.js +165 -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 +82 -0
- package/dist/Tooltip/Tooltip.d.ts.map +1 -0
- package/dist/Tooltip/Tooltip.js +124 -0
- package/dist/Tooltip/Tooltip.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -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/dist/slotProps.d.ts +110 -0
- package/dist/slotProps.d.ts.map +1 -0
- package/dist/slotProps.js +39 -0
- package/dist/slotProps.js.map +1 -0
- 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 +144 -0
- package/src/Field/Field.stories.tsx +280 -0
- package/src/Field/Field.test.tsx +344 -0
- package/src/Field/Field.tsx +212 -0
- package/src/Field/FieldGroup.tsx +138 -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 +123 -0
- package/src/LogViewer/LogViewer.stories.tsx +169 -0
- package/src/LogViewer/LogViewer.test.tsx +236 -0
- package/src/LogViewer/LogViewer.tsx +261 -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 +193 -0
- package/src/index.ts +47 -0
- package/src/mountStory.testHelpers.ts +30 -0
- package/src/slotProps.ts +143 -0
- package/src/sourceRules.test.ts +221 -30
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useUniqueId } from "@charcuterie/logic";
|
|
3
|
+
import { toClassName } from "../toClassName.js";
|
|
4
|
+
/**
|
|
5
|
+
* A label over **several** controls.
|
|
6
|
+
*
|
|
7
|
+
* `Field` mints one id and clones it onto one child, which is the
|
|
8
|
+
* right shape for a text input and the wrong shape the moment there
|
|
9
|
+
* are two of them: an `id` can only name one element, and a
|
|
10
|
+
* `<label htmlFor>` can only point at one. Six of mux-magic's sixteen
|
|
11
|
+
* field components are in that position — `RegexWithFlagsField`
|
|
12
|
+
* (pattern, flags, sample, and a checkbox), `NumberWithLookupField`
|
|
13
|
+
* (three inputs), `LanguageCodeField`, `LanguageCodesField`,
|
|
14
|
+
* `SubtitleTypesField` (a tag input beside a dropdown) and
|
|
15
|
+
* `RenameRegexField` (a row per rule) — and every one of them renders
|
|
16
|
+
* a `FieldLabel` whose `htmlFor` names, at best, one of them.
|
|
17
|
+
*
|
|
18
|
+
* ### `<fieldset>` and `<legend>`, not a `<label>`
|
|
19
|
+
*
|
|
20
|
+
* This is the one place in the library where `<fieldset>` is right.
|
|
21
|
+
* `AccordionSection` rejected it for its panel and said why — it is a
|
|
22
|
+
* *form-control grouping*, and dragging `<legend>` semantics onto
|
|
23
|
+
* prose is wrong. Here the content **is** a form-control grouping,
|
|
24
|
+
* so the element means what it says, `<legend>` names the group
|
|
25
|
+
* natively, and no `role="group"` and no `aria-labelledby` are
|
|
26
|
+
* needed.
|
|
27
|
+
*
|
|
28
|
+
* A `<label>` here would be the mux-magic defect deliberately: a
|
|
29
|
+
* label pointing at one control, or at none.
|
|
30
|
+
*
|
|
31
|
+
* ### What a group cannot carry
|
|
32
|
+
*
|
|
33
|
+
* `aria-invalid` has no group form — it belongs on the control that
|
|
34
|
+
* is actually invalid, and this component does not know which one
|
|
35
|
+
* that is. So `error` here is *described*, not *asserted*: it joins
|
|
36
|
+
* `aria-describedby` on the `<fieldset>`, which screen readers read
|
|
37
|
+
* as focus enters the group. A control inside that is itself invalid
|
|
38
|
+
* should say so itself, in its own `Field`.
|
|
39
|
+
*
|
|
40
|
+
* That is a real limitation and it is stated rather than papered
|
|
41
|
+
* over — the alternative was to clone `aria-invalid` onto every
|
|
42
|
+
* child, which marks the valid ones invalid.
|
|
43
|
+
*/
|
|
44
|
+
export const FieldGroup = ({ children, className, description, error, isRequired = false, label, }) => {
|
|
45
|
+
const baseId = useUniqueId();
|
|
46
|
+
const descriptionId = `${baseId}-description`;
|
|
47
|
+
const errorId = `${baseId}-error`;
|
|
48
|
+
// Description first, error second, for the reason `Field` gives:
|
|
49
|
+
// a screen reader reads the list in order and the two sentences
|
|
50
|
+
// are not interchangeable.
|
|
51
|
+
const describedBy = [
|
|
52
|
+
description === undefined ? null : descriptionId,
|
|
53
|
+
error === undefined ? null : errorId,
|
|
54
|
+
]
|
|
55
|
+
.filter(Boolean)
|
|
56
|
+
.join(" ");
|
|
57
|
+
return (_jsxs("fieldset", { "aria-describedby": describedBy === "" ? undefined : describedBy, className: toClassName(
|
|
58
|
+
// A `<fieldset>` arrives with a border, an inline margin, a
|
|
59
|
+
// block padding, and `min-inline-size: min-content` — the
|
|
60
|
+
// last of which makes it refuse to shrink inside a flex or
|
|
61
|
+
// grid parent, which is the reason design systems avoid the
|
|
62
|
+
// element. All four are cleared here, once.
|
|
63
|
+
"m-0 flex min-w-0 flex-col gap-1.5 border-0 p-0", className), children: [_jsxs("legend", { className: "mb-1.5 p-0 font-medium text-content-primary text-sm", children: [label, isRequired ? (_jsx("span", { "aria-hidden": "true", className: "ms-1 text-intent-danger-content", children: "*" })) : null] }), children, description === undefined ? null : (_jsx("p", { className: "text-content-secondary text-xs", id: descriptionId, children: description })), error === undefined ? null : (_jsx("p", { className: "text-intent-danger-content text-xs", id: errorId, children: error }))] }));
|
|
64
|
+
};
|
|
65
|
+
//# sourceMappingURL=FieldGroup.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"FieldGroup.js","sourceRoot":"","sources":["../../src/Field/FieldGroup.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AAGhD,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAkB/C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG,CAAC,EACzB,QAAQ,EACR,SAAS,EACT,WAAW,EACX,KAAK,EACL,UAAU,GAAG,KAAK,EAClB,KAAK,GACW,EAAa,EAAE;IAC/B,MAAM,MAAM,GAAG,WAAW,EAAE,CAAA;IAE5B,MAAM,aAAa,GAAG,GAAG,MAAM,cAAc,CAAA;IAE7C,MAAM,OAAO,GAAG,GAAG,MAAM,QAAQ,CAAA;IAEjC,iEAAiE;IACjE,gEAAgE;IAChE,2BAA2B;IAC3B,MAAM,WAAW,GAAG;QAClB,WAAW,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,aAAa;QAChD,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,OAAO;KACrC;SACE,MAAM,CAAC,OAAO,CAAC;SACf,IAAI,CAAC,GAAG,CAAC,CAAA;IAEZ,OAAO,CACL,wCAEI,WAAW,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,WAAW,EAE9C,SAAS,EAAE,WAAW;QACpB,4DAA4D;QAC5D,0DAA0D;QAC1D,2DAA2D;QAC3D,4DAA4D;QAC5D,4CAA4C;QAC5C,gDAAgD,EAChD,SAAS,CACV,aAED,kBAAQ,SAAS,EAAC,qDAAqD,aACpE,KAAK,EAEL,UAAU,CAAC,CAAC,CAAC,CACZ,8BAIc,MAAM,EAClB,SAAS,EAAC,iCAAiC,kBAGtC,CACR,CAAC,CAAC,CAAC,IAAI,IACD,EAER,QAAQ,EAER,WAAW,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAClC,YACE,SAAS,EAAC,gCAAgC,EAC1C,EAAE,EAAE,aAAa,YAEhB,WAAW,GACV,CACL,EAEA,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAC5B,YACE,SAAS,EAAC,oCAAoC,EAC9C,EAAE,EAAE,OAAO,YAEV,KAAK,GACJ,CACL,IACQ,CACZ,CAAA;AACH,CAAC,CAAA"}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
export type FileDropZoneProps = {
|
|
3
|
+
/** An `accept` list, passed straight to the input. */
|
|
4
|
+
accept?: string;
|
|
5
|
+
className?: string;
|
|
6
|
+
/** Secondary line — the formats taken, a size cap. */
|
|
7
|
+
description?: ReactNode;
|
|
8
|
+
isDisabled?: boolean;
|
|
9
|
+
isMultiple?: boolean;
|
|
10
|
+
/** The control's accessible name, and its visible prompt. */
|
|
11
|
+
label: string;
|
|
12
|
+
onDropFiles: (files: File[]) => void;
|
|
13
|
+
/**
|
|
14
|
+
* Dropped text, when there is any. A browser hands over a dragged
|
|
15
|
+
* link as `text/plain` and no files at all, which is what
|
|
16
|
+
* gallery-downloader's page is built entirely around — it takes
|
|
17
|
+
* `dataTransfer.getData("text")` and never looks at `.files`.
|
|
18
|
+
*/
|
|
19
|
+
onDropText?: (text: string) => void;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* The one drag target in the fleet is gallery-downloader's, and it
|
|
23
|
+
* gets the hard part right: `dragenter`/`dragleave` fire once per
|
|
24
|
+
* *element* the pointer crosses, not once per zone, so a naive
|
|
25
|
+
* implementation flickers over every child — and that page keeps a
|
|
26
|
+
* `dragDepth` counter, which is the correct fix. It is reproduced
|
|
27
|
+
* below with credit.
|
|
28
|
+
*
|
|
29
|
+
* What it has no answer for is the part that cannot be fixed by
|
|
30
|
+
* counting:
|
|
31
|
+
*
|
|
32
|
+
* ### Dragging is pointer-only, so the zone is a label around an input
|
|
33
|
+
*
|
|
34
|
+
* There is **no keyboard gesture for drag-and-drop**. WCAG 2.5.7
|
|
35
|
+
* (AA since 2.2) requires a single-pointer alternative for any
|
|
36
|
+
* dragging movement, and the honest one has existed since 1995: a
|
|
37
|
+
* real `<input type="file">`. So this component is a `<label>`
|
|
38
|
+
* wrapping a visually-hidden input — the whole zone is the label's
|
|
39
|
+
* click target, Tab reaches the input, Space and Enter open the
|
|
40
|
+
* picker, and the drop handlers are an *enhancement* on top of a
|
|
41
|
+
* control that already works without them.
|
|
42
|
+
*
|
|
43
|
+
* That ordering is the entire design. Building the drop zone first
|
|
44
|
+
* and bolting on a "or browse" link is how the fleet's version ended
|
|
45
|
+
* up with an `alert()` as its error channel and no keyboard path at
|
|
46
|
+
* all.
|
|
47
|
+
*
|
|
48
|
+
* ### `dragover` must be prevented, and the reason is a footgun
|
|
49
|
+
*
|
|
50
|
+
* A browser's default for `dragover` is *reject the drop*. Not
|
|
51
|
+
* preventing it means `drop` never fires — nothing errors, the
|
|
52
|
+
* cursor shows a "no entry" sign, and the zone looks like it has a
|
|
53
|
+
* broken handler when it in fact has no handler running at all.
|
|
54
|
+
*
|
|
55
|
+
* ### It reports text as well as files
|
|
56
|
+
*
|
|
57
|
+
* Dragging a link gives you `text/plain` and an empty `files` list.
|
|
58
|
+
* A zone that only reads `.files` silently ignores the single most
|
|
59
|
+
* common thing a user drags into a downloader, which is why
|
|
60
|
+
* `onDropText` is here rather than being someone's follow-up.
|
|
61
|
+
*/
|
|
62
|
+
export declare const FileDropZone: ({ accept, className, description, isDisabled, isMultiple, label, onDropFiles, onDropText, }: FileDropZoneProps) => ReactNode;
|
|
63
|
+
//# sourceMappingURL=FileDropZone.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"FileDropZone.d.ts","sourceRoot":"","sources":["../../src/FileDropZone/FileDropZone.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAa,SAAS,EAAE,MAAM,OAAO,CAAA;AAKjD,MAAM,MAAM,iBAAiB,GAAG;IAC9B,sDAAsD;IACtD,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,sDAAsD;IACtD,WAAW,CAAC,EAAE,SAAS,CAAA;IACvB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,6DAA6D;IAC7D,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,EAAE,CAAC,KAAK,EAAE,IAAI,EAAE,KAAK,IAAI,CAAA;IACpC;;;;;OAKG;IACH,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;CACpC,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,eAAO,MAAM,YAAY,GAAI,6FAS1B,iBAAiB,KAAG,SA6JtB,CAAA"}
|
|
@@ -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,90 @@
|
|
|
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. A fifth, below the effects, is not
|
|
31
|
+
* theirs: it is defect 1 coming back through the front door, in this
|
|
32
|
+
* component, found by the first app to consume it.
|
|
33
|
+
*
|
|
34
|
+
* ### 1. The one that follows nothing
|
|
35
|
+
*
|
|
36
|
+
* ```tsx
|
|
37
|
+
* useEffect(() => {
|
|
38
|
+
* const pane = paneRef.current
|
|
39
|
+
* if (pane) pane.scrollTop = pane.scrollHeight
|
|
40
|
+
* }, []) // ← empty deps
|
|
41
|
+
* ```
|
|
42
|
+
*
|
|
43
|
+
* That is mux-magic's auto-scroll, and it runs **once**, on mount,
|
|
44
|
+
* when the pane is empty and `scrollHeight` is `clientHeight`. Every
|
|
45
|
+
* line after it arrives below the fold. It reads as correct, it is
|
|
46
|
+
* the shape everyone writes, and the log simply never follows.
|
|
47
|
+
*
|
|
48
|
+
* ### 2. Following that cannot be escaped
|
|
49
|
+
*
|
|
50
|
+
* The naive fix — scroll to the bottom on every render — is worse:
|
|
51
|
+
* a user scrolling back to read the line that mentioned an error is
|
|
52
|
+
* yanked to the end twice a second. Following has to be *pinned to
|
|
53
|
+
* the user's own position*, which is what `isFollowing` below is: it
|
|
54
|
+
* turns off the moment the user scrolls away and back on when they
|
|
55
|
+
* return to the bottom, and there is a button for when they do not
|
|
56
|
+
* want to scroll all that way.
|
|
57
|
+
*
|
|
58
|
+
* ### 3. `data-log-id`
|
|
59
|
+
*
|
|
60
|
+
* mux-magic's pane carries one. It is a `data-testid` under a
|
|
61
|
+
* different name — a handle only the test suite can see — and this
|
|
62
|
+
* package's `sourceRules.test.ts` bans the practice rather than the
|
|
63
|
+
* spelling. The pane here is found by its `label`, the same query
|
|
64
|
+
* Playwright makes.
|
|
65
|
+
*
|
|
66
|
+
* ### 4. Announcing an hour of build output
|
|
67
|
+
*
|
|
68
|
+
* `role="log"` is the correct role and `aria-live` is off unless
|
|
69
|
+
* asked for. A live log pane emitting a line every 300 ms is a
|
|
70
|
+
* screen reader that cannot be interrupted and a user who cannot
|
|
71
|
+
* read anything else on the page — the accessible answer for a
|
|
72
|
+
* long-running job is a `LiveStatusIndicator` announcing *state*
|
|
73
|
+
* and a log that is there to be read when wanted. `isAnnounced`
|
|
74
|
+
* exists for the short case: a five-line result panel where the
|
|
75
|
+
* lines *are* the outcome.
|
|
76
|
+
*
|
|
77
|
+
* ### Not every boolean is a state kind
|
|
78
|
+
*
|
|
79
|
+
* `isFollowing` is `useState`, not `useVisibility`, and this is the
|
|
80
|
+
* first component in the library to say so out loud. The kinds in
|
|
81
|
+
* `@charcuterie/logic` earn their keep when state is **registered**
|
|
82
|
+
* (members join a group), **composed** (focus and selection
|
|
83
|
+
* disagreeing), or **shared** (a trigger and a panel at opposite
|
|
84
|
+
* ends of a tree). None of that is true here: one component reads
|
|
85
|
+
* one boolean it wrote itself. Reaching for `useVisibility` because
|
|
86
|
+
* it is also a boolean would make `isVisible` mean "is following",
|
|
87
|
+
* which is how a state layer stops meaning anything.
|
|
88
|
+
*/
|
|
89
|
+
export declare const LogViewer: ({ className, isAnnounced, label, lines, maxLines, }: LogViewerProps) => ReactNode;
|
|
90
|
+
//# 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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8DG;AACH,eAAO,MAAM,SAAS,GAAI,qDAMvB,cAAc,KAAG,SA8JnB,CAAA"}
|
|
@@ -0,0 +1,165 @@
|
|
|
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. A fifth, below the effects, is not
|
|
11
|
+
* theirs: it is defect 1 coming back through the front door, in this
|
|
12
|
+
* component, found by the first app to consume it.
|
|
13
|
+
*
|
|
14
|
+
* ### 1. The one that follows nothing
|
|
15
|
+
*
|
|
16
|
+
* ```tsx
|
|
17
|
+
* useEffect(() => {
|
|
18
|
+
* const pane = paneRef.current
|
|
19
|
+
* if (pane) pane.scrollTop = pane.scrollHeight
|
|
20
|
+
* }, []) // ← empty deps
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* That is mux-magic's auto-scroll, and it runs **once**, on mount,
|
|
24
|
+
* when the pane is empty and `scrollHeight` is `clientHeight`. Every
|
|
25
|
+
* line after it arrives below the fold. It reads as correct, it is
|
|
26
|
+
* the shape everyone writes, and the log simply never follows.
|
|
27
|
+
*
|
|
28
|
+
* ### 2. Following that cannot be escaped
|
|
29
|
+
*
|
|
30
|
+
* The naive fix — scroll to the bottom on every render — is worse:
|
|
31
|
+
* a user scrolling back to read the line that mentioned an error is
|
|
32
|
+
* yanked to the end twice a second. Following has to be *pinned to
|
|
33
|
+
* the user's own position*, which is what `isFollowing` below is: it
|
|
34
|
+
* turns off the moment the user scrolls away and back on when they
|
|
35
|
+
* return to the bottom, and there is a button for when they do not
|
|
36
|
+
* want to scroll all that way.
|
|
37
|
+
*
|
|
38
|
+
* ### 3. `data-log-id`
|
|
39
|
+
*
|
|
40
|
+
* mux-magic's pane carries one. It is a `data-testid` under a
|
|
41
|
+
* different name — a handle only the test suite can see — and this
|
|
42
|
+
* package's `sourceRules.test.ts` bans the practice rather than the
|
|
43
|
+
* spelling. The pane here is found by its `label`, the same query
|
|
44
|
+
* Playwright makes.
|
|
45
|
+
*
|
|
46
|
+
* ### 4. Announcing an hour of build output
|
|
47
|
+
*
|
|
48
|
+
* `role="log"` is the correct role and `aria-live` is off unless
|
|
49
|
+
* asked for. A live log pane emitting a line every 300 ms is a
|
|
50
|
+
* screen reader that cannot be interrupted and a user who cannot
|
|
51
|
+
* read anything else on the page — the accessible answer for a
|
|
52
|
+
* long-running job is a `LiveStatusIndicator` announcing *state*
|
|
53
|
+
* and a log that is there to be read when wanted. `isAnnounced`
|
|
54
|
+
* exists for the short case: a five-line result panel where the
|
|
55
|
+
* lines *are* the outcome.
|
|
56
|
+
*
|
|
57
|
+
* ### Not every boolean is a state kind
|
|
58
|
+
*
|
|
59
|
+
* `isFollowing` is `useState`, not `useVisibility`, and this is the
|
|
60
|
+
* first component in the library to say so out loud. The kinds in
|
|
61
|
+
* `@charcuterie/logic` earn their keep when state is **registered**
|
|
62
|
+
* (members join a group), **composed** (focus and selection
|
|
63
|
+
* disagreeing), or **shared** (a trigger and a panel at opposite
|
|
64
|
+
* ends of a tree). None of that is true here: one component reads
|
|
65
|
+
* one boolean it wrote itself. Reaching for `useVisibility` because
|
|
66
|
+
* it is also a boolean would make `isVisible` mean "is following",
|
|
67
|
+
* which is how a state layer stops meaning anything.
|
|
68
|
+
*/
|
|
69
|
+
export const LogViewer = ({ className, isAnnounced = false, label, lines, maxLines = 500, }) => {
|
|
70
|
+
const paneRef = useRef(null);
|
|
71
|
+
const [isFollowing, setIsFollowing] = useState(true);
|
|
72
|
+
const shownLines = lines.length > maxLines
|
|
73
|
+
? lines.slice(lines.length - maxLines)
|
|
74
|
+
: lines;
|
|
75
|
+
// 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.
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
const pane = paneRef.current;
|
|
78
|
+
if (!pane || !isFollowing) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
pane.scrollTop = pane.scrollHeight;
|
|
82
|
+
// `shownLines` rather than an empty array — this is the whole fix
|
|
83
|
+
// for defect 1, and the dependency *is* the fix.
|
|
84
|
+
}, [isFollowing, shownLines]);
|
|
85
|
+
/**
|
|
86
|
+
* Defect 1 again, and the effect above cannot see it: **a pane can
|
|
87
|
+
* be measured before it has a box.**
|
|
88
|
+
*
|
|
89
|
+
* `AccordionSection` renders its panel with `hidden` rather than
|
|
90
|
+
* unmounting it, deliberately — "a panel that unmounts loses a
|
|
91
|
+
* scroll position, a partially typed form, and any subscription its
|
|
92
|
+
* content opened — and the fleet's log panes are exactly that." A
|
|
93
|
+
* `hidden` subtree has no layout, so the effect above runs on mount
|
|
94
|
+
* with `scrollHeight 0` and writes `scrollTop = 0`, and then never
|
|
95
|
+
* runs again: neither `isFollowing` nor `shownLines` changes when
|
|
96
|
+
* the section is opened. Measured in mux-magic on a 60-line pane:
|
|
97
|
+
*
|
|
98
|
+
* ```
|
|
99
|
+
* while collapsed : scrollTop 0 scrollHeight 0 clientHeight 0
|
|
100
|
+
* after expanding : scrollTop 0 scrollHeight 976 clientHeight 254
|
|
101
|
+
* ```
|
|
102
|
+
*
|
|
103
|
+
* That is this component's own `}, [])` bug, rebuilt out of two
|
|
104
|
+
* components whose individual decisions are both right, and
|
|
105
|
+
* invisible to both of their test suites — `LogViewer`'s mounted
|
|
106
|
+
* visible, `Accordion`'s holding content that never measures
|
|
107
|
+
* itself. mux-magic hit it in its first week as a consumer and
|
|
108
|
+
* worked around it downstream, in a `DisclosedLogViewer` that
|
|
109
|
+
* withheld the pane until the section had been opened once. This is
|
|
110
|
+
* that workaround coming into the library so it can be deleted.
|
|
111
|
+
*
|
|
112
|
+
* ### Why the box and not the viewport
|
|
113
|
+
*
|
|
114
|
+
* An `IntersectionObserver` answers "is it on screen", which is a
|
|
115
|
+
* different question with two wrong answers here: a pane below the
|
|
116
|
+
* fold on a long page is *not* intersecting and has perfectly good
|
|
117
|
+
* layout, and scrolling the page later would re-fire for no reason.
|
|
118
|
+
* `ResizeObserver` answers "does it have a box, and is that box
|
|
119
|
+
* still the same size" — which is the precondition the measurement
|
|
120
|
+
* above actually needs. Per spec it does not fire at `observe()`
|
|
121
|
+
* time for an element that is not being rendered, so gaining a box
|
|
122
|
+
* *is* the first callback, and that is the reveal.
|
|
123
|
+
*
|
|
124
|
+
* It pays for itself twice: a window resize re-wraps the lines and
|
|
125
|
+
* changes what is at the bottom, and a following pane should be at
|
|
126
|
+
* the new bottom rather than a few lines above it.
|
|
127
|
+
*
|
|
128
|
+
* Only while following. A user who scrolled up to read an error is
|
|
129
|
+
* not dragged back by resizing their window.
|
|
130
|
+
*/
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
const pane = paneRef.current;
|
|
133
|
+
if (!pane || !isFollowing) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const observer = new ResizeObserver(() => {
|
|
137
|
+
pane.scrollTop = pane.scrollHeight;
|
|
138
|
+
});
|
|
139
|
+
observer.observe(pane);
|
|
140
|
+
return () => {
|
|
141
|
+
observer.disconnect();
|
|
142
|
+
};
|
|
143
|
+
}, [isFollowing]);
|
|
144
|
+
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) => {
|
|
145
|
+
const pane = scrollEvent.currentTarget;
|
|
146
|
+
// A few pixels of slack, because a fractional device pixel
|
|
147
|
+
// ratio means `scrollTop + clientHeight` never lands
|
|
148
|
+
// exactly on `scrollHeight` — an equality test here
|
|
149
|
+
// un-pins the pane on a 125% display and on nothing else,
|
|
150
|
+
// which is the kind of bug that gets reported as "it works
|
|
151
|
+
// on my machine".
|
|
152
|
+
const isPinnedToBottom = pane.scrollHeight -
|
|
153
|
+
pane.scrollTop -
|
|
154
|
+
pane.clientHeight <
|
|
155
|
+
4;
|
|
156
|
+
setIsFollowing(isPinnedToBottom);
|
|
157
|
+
}, ref: paneRef, role: "log",
|
|
158
|
+
// 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.
|
|
159
|
+
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
|
|
160
|
+
? null
|
|
161
|
+
: 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: () => {
|
|
162
|
+
setIsFollowing(true);
|
|
163
|
+
}, size: "sm", children: "Jump to latest" }))] }));
|
|
164
|
+
};
|
|
165
|
+
//# 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"}
|