@charcuterie/ui 0.1.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/CHANGELOG.md +13 -0
- package/README.md +161 -0
- package/dist/Alert/Alert.d.ts +88 -0
- package/dist/Alert/Alert.d.ts.map +1 -0
- package/dist/Alert/Alert.js +67 -0
- package/dist/Alert/Alert.js.map +1 -0
- package/dist/Badge/Badge.d.ts +54 -0
- package/dist/Badge/Badge.d.ts.map +1 -0
- package/dist/Badge/Badge.js +54 -0
- package/dist/Badge/Badge.js.map +1 -0
- package/dist/Badge/useIsTextClipped.d.ts +22 -0
- package/dist/Badge/useIsTextClipped.d.ts.map +1 -0
- package/dist/Badge/useIsTextClipped.js +57 -0
- package/dist/Badge/useIsTextClipped.js.map +1 -0
- package/dist/Button/Button.d.ts +58 -0
- package/dist/Button/Button.d.ts.map +1 -0
- package/dist/Button/Button.js +37 -0
- package/dist/Button/Button.js.map +1 -0
- package/dist/Card/Card.d.ts +44 -0
- package/dist/Card/Card.d.ts.map +1 -0
- package/dist/Card/Card.js +59 -0
- package/dist/Card/Card.js.map +1 -0
- package/dist/EmptyState/EmptyState.d.ts +42 -0
- package/dist/EmptyState/EmptyState.d.ts.map +1 -0
- package/dist/EmptyState/EmptyState.js +37 -0
- package/dist/EmptyState/EmptyState.js.map +1 -0
- package/dist/IconButton/IconButton.d.ts +34 -0
- package/dist/IconButton/IconButton.d.ts.map +1 -0
- package/dist/IconButton/IconButton.js +23 -0
- package/dist/IconButton/IconButton.js.map +1 -0
- package/dist/LiveStatusIndicator/LiveStatusIndicator.d.ts +37 -0
- package/dist/LiveStatusIndicator/LiveStatusIndicator.d.ts.map +1 -0
- package/dist/LiveStatusIndicator/LiveStatusIndicator.js +39 -0
- package/dist/LiveStatusIndicator/LiveStatusIndicator.js.map +1 -0
- package/dist/MediaTile/MediaTile.d.ts +46 -0
- package/dist/MediaTile/MediaTile.d.ts.map +1 -0
- package/dist/MediaTile/MediaTile.js +79 -0
- package/dist/MediaTile/MediaTile.js.map +1 -0
- package/dist/MediaTile/mediaStatus.d.ts +22 -0
- package/dist/MediaTile/mediaStatus.d.ts.map +1 -0
- package/dist/MediaTile/mediaStatus.js +24 -0
- package/dist/MediaTile/mediaStatus.js.map +1 -0
- package/dist/Modal/Modal.d.ts +66 -0
- package/dist/Modal/Modal.d.ts.map +1 -0
- package/dist/Modal/Modal.js +111 -0
- package/dist/Modal/Modal.js.map +1 -0
- package/dist/Modal/lockScrollBehind.d.ts +22 -0
- package/dist/Modal/lockScrollBehind.d.ts.map +1 -0
- package/dist/Modal/lockScrollBehind.js +48 -0
- package/dist/Modal/lockScrollBehind.js.map +1 -0
- package/dist/Popover/Popover.d.ts +66 -0
- package/dist/Popover/Popover.d.ts.map +1 -0
- package/dist/Popover/Popover.js +88 -0
- package/dist/Popover/Popover.js.map +1 -0
- package/dist/ProgressBar/ProgressBar.d.ts +51 -0
- package/dist/ProgressBar/ProgressBar.d.ts.map +1 -0
- package/dist/ProgressBar/ProgressBar.js +50 -0
- package/dist/ProgressBar/ProgressBar.js.map +1 -0
- package/dist/ProgressBar/progressValue.d.ts +44 -0
- package/dist/ProgressBar/progressValue.d.ts.map +1 -0
- package/dist/ProgressBar/progressValue.js +55 -0
- package/dist/ProgressBar/progressValue.js.map +1 -0
- package/dist/SegmentedControl/SegmentedControl.d.ts +59 -0
- package/dist/SegmentedControl/SegmentedControl.d.ts.map +1 -0
- package/dist/SegmentedControl/SegmentedControl.js +129 -0
- package/dist/SegmentedControl/SegmentedControl.js.map +1 -0
- package/dist/SegmentedControl/SegmentedOption.d.ts +28 -0
- package/dist/SegmentedControl/SegmentedOption.d.ts.map +1 -0
- package/dist/SegmentedControl/SegmentedOption.js +40 -0
- package/dist/SegmentedControl/SegmentedOption.js.map +1 -0
- package/dist/Skeleton/Skeleton.d.ts +30 -0
- package/dist/Skeleton/Skeleton.d.ts.map +1 -0
- package/dist/Skeleton/Skeleton.js +43 -0
- package/dist/Skeleton/Skeleton.js.map +1 -0
- package/dist/Spinner/Spinner.d.ts +37 -0
- package/dist/Spinner/Spinner.d.ts.map +1 -0
- package/dist/Spinner/Spinner.js +33 -0
- package/dist/Spinner/Spinner.js.map +1 -0
- package/dist/Tabs/TabTrigger.d.ts +32 -0
- package/dist/Tabs/TabTrigger.d.ts.map +1 -0
- package/dist/Tabs/TabTrigger.js +62 -0
- package/dist/Tabs/TabTrigger.js.map +1 -0
- package/dist/Tabs/Tabs.d.ts +95 -0
- package/dist/Tabs/Tabs.d.ts.map +1 -0
- package/dist/Tabs/Tabs.js +195 -0
- package/dist/Tabs/Tabs.js.map +1 -0
- package/dist/VisuallyHidden/VisuallyHidden.d.ts +25 -0
- package/dist/VisuallyHidden/VisuallyHidden.d.ts.map +1 -0
- package/dist/VisuallyHidden/VisuallyHidden.js +23 -0
- package/dist/VisuallyHidden/VisuallyHidden.js.map +1 -0
- package/dist/controlStyles.d.ts +50 -0
- package/dist/controlStyles.d.ts.map +1 -0
- package/dist/controlStyles.js +60 -0
- package/dist/controlStyles.js.map +1 -0
- package/dist/expectNoAxeViolations.testHelpers.d.ts +19 -0
- package/dist/expectNoAxeViolations.testHelpers.d.ts.map +1 -0
- package/dist/expectNoAxeViolations.testHelpers.js +32 -0
- package/dist/expectNoAxeViolations.testHelpers.js.map +1 -0
- package/dist/index.d.ts +75 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +55 -0
- package/dist/index.js.map +1 -0
- package/dist/intentStyles.d.ts +76 -0
- package/dist/intentStyles.d.ts.map +1 -0
- package/dist/intentStyles.js +150 -0
- package/dist/intentStyles.js.map +1 -0
- package/dist/mountStory.testHelpers.d.ts +30 -0
- package/dist/mountStory.testHelpers.d.ts.map +1 -0
- package/dist/mountStory.testHelpers.js +19 -0
- package/dist/mountStory.testHelpers.js.map +1 -0
- package/dist/statusIntent.d.ts +33 -0
- package/dist/statusIntent.d.ts.map +1 -0
- package/dist/statusIntent.js +99 -0
- package/dist/statusIntent.js.map +1 -0
- package/dist/styles.css +130 -0
- package/dist/testing/expectAgentDrivable.d.ts +53 -0
- package/dist/testing/expectAgentDrivable.d.ts.map +1 -0
- package/dist/testing/expectAgentDrivable.js +163 -0
- package/dist/testing/expectAgentDrivable.js.map +1 -0
- package/dist/testing/index.d.ts +11 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +10 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/toClassName.d.ts +12 -0
- package/dist/toClassName.d.ts.map +1 -0
- package/dist/toClassName.js +12 -0
- package/dist/toClassName.js.map +1 -0
- package/dist/tokens.d.ts +16 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +16 -0
- package/dist/tokens.js.map +1 -0
- package/package.json +70 -0
- package/src/Alert/Alert.mdx +70 -0
- package/src/Alert/Alert.stories.tsx +218 -0
- package/src/Alert/Alert.test.tsx +112 -0
- package/src/Alert/Alert.tsx +166 -0
- package/src/Badge/Badge.mdx +43 -0
- package/src/Badge/Badge.stories.tsx +229 -0
- package/src/Badge/Badge.test.tsx +153 -0
- package/src/Badge/Badge.tsx +130 -0
- package/src/Badge/useIsTextClipped.ts +77 -0
- package/src/Button/Button.mdx +44 -0
- package/src/Button/Button.stories.tsx +175 -0
- package/src/Button/Button.test.tsx +114 -0
- package/src/Button/Button.tsx +125 -0
- package/src/Card/Card.mdx +53 -0
- package/src/Card/Card.stories.tsx +207 -0
- package/src/Card/Card.test.tsx +56 -0
- package/src/Card/Card.tsx +142 -0
- package/src/EmptyState/EmptyState.mdx +40 -0
- package/src/EmptyState/EmptyState.stories.tsx +183 -0
- package/src/EmptyState/EmptyState.test.tsx +41 -0
- package/src/EmptyState/EmptyState.tsx +114 -0
- package/src/IconButton/IconButton.mdx +34 -0
- package/src/IconButton/IconButton.stories.tsx +191 -0
- package/src/IconButton/IconButton.test.tsx +70 -0
- package/src/IconButton/IconButton.tsx +54 -0
- package/src/LiveStatusIndicator/LiveStatusIndicator.mdx +43 -0
- package/src/LiveStatusIndicator/LiveStatusIndicator.stories.tsx +199 -0
- package/src/LiveStatusIndicator/LiveStatusIndicator.test.tsx +63 -0
- package/src/LiveStatusIndicator/LiveStatusIndicator.tsx +116 -0
- package/src/MediaTile/MediaTile.mdx +51 -0
- package/src/MediaTile/MediaTile.stories.tsx +211 -0
- package/src/MediaTile/MediaTile.test.tsx +41 -0
- package/src/MediaTile/MediaTile.tsx +227 -0
- package/src/MediaTile/mediaStatus.ts +28 -0
- package/src/Modal/Modal.mdx +113 -0
- package/src/Modal/Modal.stories.tsx +272 -0
- package/src/Modal/Modal.test.tsx +248 -0
- package/src/Modal/Modal.tsx +228 -0
- package/src/Modal/lockScrollBehind.ts +59 -0
- package/src/Popover/Popover.mdx +102 -0
- package/src/Popover/Popover.stories.tsx +247 -0
- package/src/Popover/Popover.test.tsx +191 -0
- package/src/Popover/Popover.tsx +164 -0
- package/src/ProgressBar/ProgressBar.mdx +48 -0
- package/src/ProgressBar/ProgressBar.stories.tsx +232 -0
- package/src/ProgressBar/ProgressBar.test.tsx +74 -0
- package/src/ProgressBar/ProgressBar.tsx +172 -0
- package/src/ProgressBar/progressValue.test.ts +101 -0
- package/src/ProgressBar/progressValue.ts +87 -0
- package/src/SegmentedControl/SegmentedControl.mdx +71 -0
- package/src/SegmentedControl/SegmentedControl.stories.tsx +150 -0
- package/src/SegmentedControl/SegmentedControl.test.tsx +208 -0
- package/src/SegmentedControl/SegmentedControl.tsx +219 -0
- package/src/SegmentedControl/SegmentedOption.tsx +94 -0
- package/src/Skeleton/Skeleton.mdx +30 -0
- package/src/Skeleton/Skeleton.stories.tsx +130 -0
- package/src/Skeleton/Skeleton.test.tsx +47 -0
- package/src/Skeleton/Skeleton.tsx +106 -0
- package/src/Spinner/Spinner.mdx +36 -0
- package/src/Spinner/Spinner.stories.tsx +118 -0
- package/src/Spinner/Spinner.test.tsx +38 -0
- package/src/Spinner/Spinner.tsx +92 -0
- package/src/Tabs/TabTrigger.tsx +126 -0
- package/src/Tabs/Tabs.mdx +126 -0
- package/src/Tabs/Tabs.stories.tsx +187 -0
- package/src/Tabs/Tabs.test.tsx +283 -0
- package/src/Tabs/Tabs.tsx +333 -0
- package/src/VisuallyHidden/VisuallyHidden.mdx +29 -0
- package/src/VisuallyHidden/VisuallyHidden.stories.tsx +58 -0
- package/src/VisuallyHidden/VisuallyHidden.test.tsx +31 -0
- package/src/VisuallyHidden/VisuallyHidden.tsx +43 -0
- package/src/argTypes.storyHelpers.ts +115 -0
- package/src/board.storyHelpers.tsx +150 -0
- package/src/controlStyles.ts +79 -0
- package/src/expectNoAxeViolations.testHelpers.ts +41 -0
- package/src/icons.storyHelpers.tsx +116 -0
- package/src/index.ts +132 -0
- package/src/intentStyles.ts +207 -0
- package/src/mdxReferences.test.ts +95 -0
- package/src/mountStory.testHelpers.ts +41 -0
- package/src/sourceRules.test.ts +303 -0
- package/src/statusIntent.test.ts +136 -0
- package/src/statusIntent.ts +137 -0
- package/src/storyControls.test.ts +128 -0
- package/src/styles.css +130 -0
- package/src/tailwindCandidates.test.ts +377 -0
- package/src/testing/expectAgentDrivable.test.tsx +71 -0
- package/src/testing/expectAgentDrivable.ts +273 -0
- package/src/testing/index.ts +17 -0
- package/src/toClassName.ts +13 -0
- package/src/tokens.ts +16 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as ModalStories from "./Modal.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={ModalStories} />
|
|
6
|
+
|
|
7
|
+
# Modal
|
|
8
|
+
|
|
9
|
+
Four repos, and **plex-channels and ripdeck arrived at native `<dialog>` independently**
|
|
10
|
+
before this library existed. That is the design, not a shortcut: `showModal()` gives a
|
|
11
|
+
focus trap, Escape, `::backdrop`, `inert` on everything behind it, and the top layer —
|
|
12
|
+
from the platform, correctly, in every browser the fleet runs on.
|
|
13
|
+
|
|
14
|
+
<Canvas of={ModalStories.Default} />
|
|
15
|
+
|
|
16
|
+
## Props
|
|
17
|
+
|
|
18
|
+
<Controls />
|
|
19
|
+
|
|
20
|
+
## The one thing a component has to add
|
|
21
|
+
|
|
22
|
+
A native `<dialog>` is a **self-owning** control: call `showModal()` and it decides it is
|
|
23
|
+
open. That is the same conflict Radix has, and it gets the same answer as everywhere else
|
|
24
|
+
here — `isVisible` is the truth, and an effect makes the element agree with it. Nothing
|
|
25
|
+
reads `dialogElement.open` as state; it is read only to avoid calling a method that would
|
|
26
|
+
throw.
|
|
27
|
+
|
|
28
|
+
```tsx
|
|
29
|
+
const { hide, isVisible, show } = useVisibility()
|
|
30
|
+
|
|
31
|
+
<Button onClick={show}>Stop the rip</Button>
|
|
32
|
+
|
|
33
|
+
<Modal heading="Stop the rip?" isVisible={isVisible} onClose={hide}>
|
|
34
|
+
…
|
|
35
|
+
</Modal>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`onClose` is every route out — the close button, Escape, a backdrop press — so there is
|
|
39
|
+
one place to wire and no way to end up with the element shut and the state still open.
|
|
40
|
+
|
|
41
|
+
## No portal, and that is load-bearing
|
|
42
|
+
|
|
43
|
+
The top layer is a **paint** concept. A `<dialog>` renders above everything while staying
|
|
44
|
+
exactly where it is in the DOM, so it needs no portal — and that matters well past
|
|
45
|
+
tidiness:
|
|
46
|
+
|
|
47
|
+
> A portal moves the node to `document.body`, **out of whatever element a test or an agent
|
|
48
|
+
> scoped its queries to.** `canvas.getByRole("dialog")` finds this modal. It would not find
|
|
49
|
+
> a portalled one, and every story here would have to reach for `screen` and give up its
|
|
50
|
+
> scoping.
|
|
51
|
+
|
|
52
|
+
The same reasoning is why `Popover` uses `popover="manual"` rather than a portal.
|
|
53
|
+
|
|
54
|
+
## `open:flex`, not `flex`
|
|
55
|
+
|
|
56
|
+
A closed `<dialog>` is hidden by a UA rule, and any `display: flex` of ours beats it — so
|
|
57
|
+
a modal laid out with a plain `flex` renders permanently, in the page flow, with no error
|
|
58
|
+
anywhere. The layout is therefore behind Tailwind's `open:` variant, keyed on the `[open]`
|
|
59
|
+
attribute the browser manages.
|
|
60
|
+
|
|
61
|
+
<Canvas of={ModalStories.AllVariants} />
|
|
62
|
+
|
|
63
|
+
## What a story cannot press
|
|
64
|
+
|
|
65
|
+
`play`'s `userEvent` is `@testing-library/user-event`, which dispatches **untrusted**
|
|
66
|
+
events, and a browser runs a default action only for trusted input. So a synthetic Escape
|
|
67
|
+
does not make a native `<dialog>` fire `cancel` — it does nothing at all, and a story
|
|
68
|
+
asserting "Escape did not close the non-dismissable dialog" would pass for the wrong
|
|
69
|
+
reason forever.
|
|
70
|
+
|
|
71
|
+
The stories dispatch the close request where the browser would raise it instead, which
|
|
72
|
+
splits the contract honestly: keystroke → `cancel` is the platform's and is not ours to
|
|
73
|
+
test; everything downstream of `cancel` is ours and is asserted. `AllStates` pairs it with
|
|
74
|
+
a differential — the dialog that refuses the close request is then closed by its own
|
|
75
|
+
button — because "still open" is also true of a dialog nothing can close at all.
|
|
76
|
+
|
|
77
|
+
<Canvas of={ModalStories.AllStates} />
|
|
78
|
+
|
|
79
|
+
## `isDismissable={false}` cancels, it does not ignore
|
|
80
|
+
|
|
81
|
+
Escape is `preventDefault`ed rather than dropped. Letting the browser close a dialog the
|
|
82
|
+
app said was not dismissable would leave `isVisible` true and the element shut — the exact
|
|
83
|
+
desync this component exists to prevent.
|
|
84
|
+
|
|
85
|
+
## Scroll behind it is locked, and the count is why
|
|
86
|
+
|
|
87
|
+
`showModal()` makes the page `inert` and then lets you scroll it anyway. The lock is a
|
|
88
|
+
counted module-level one rather than a hook, because nested dialogs are legal and a naive
|
|
89
|
+
lock/unlock pair has the inner dialog's cleanup unlock the page while the outer is still
|
|
90
|
+
open. The release is idempotent, because StrictMode runs an effect's cleanup twice.
|
|
91
|
+
|
|
92
|
+
## `dvh`, and the scrim
|
|
93
|
+
|
|
94
|
+
Every size clamps at `85dvh`; `full` takes the lot. `dvh` rather than `vh` because on a
|
|
95
|
+
phone the two differ by the address bar, and a dialog whose footer is under it is a dialog
|
|
96
|
+
with no buttons.
|
|
97
|
+
|
|
98
|
+
`::backdrop` reads `--color-scrim`, which is a **variant** token: `legible` dims at 0.84
|
|
99
|
+
and `daylight` at 0.66, because how hard a direction separates a dialog from the page is
|
|
100
|
+
exactly the sort of thing a visual direction decides. ePaper's is `transparent` — a panel
|
|
101
|
+
with no opacity cannot dim, so it says so instead of faking it. The `Responsive` story
|
|
102
|
+
asserts the computed `::backdrop` colour is really there, since a `var()` that failed to
|
|
103
|
+
inherit would just look like a design choice.
|
|
104
|
+
|
|
105
|
+
<Canvas of={ModalStories.Responsive} />
|
|
106
|
+
|
|
107
|
+
## A modal is not a container-query citizen
|
|
108
|
+
|
|
109
|
+
It gets no `ContainerBoard`. Its size is measured against the viewport by definition — it
|
|
110
|
+
is the thing covering the viewport — so boarding it at three container widths would be
|
|
111
|
+
measuring something with no bearing on it.
|
|
112
|
+
|
|
113
|
+
<Canvas of={ModalStories.Interactive} />
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { useVisibility } from "@charcuterie/logic"
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
3
|
+
import type { ReactNode } from "react"
|
|
4
|
+
|
|
5
|
+
import { toStoryChoice } from "../argTypes.storyHelpers.ts"
|
|
6
|
+
import { Button } from "../Button/Button.tsx"
|
|
7
|
+
import { StoryRow } from "../board.storyHelpers.tsx"
|
|
8
|
+
import type { ModalSize } from "./Modal.tsx"
|
|
9
|
+
import { Modal } from "./Modal.tsx"
|
|
10
|
+
|
|
11
|
+
const meta = {
|
|
12
|
+
title: "Components/Modal",
|
|
13
|
+
component: Modal,
|
|
14
|
+
parameters: { layout: "padded" },
|
|
15
|
+
argTypes: {
|
|
16
|
+
size: toStoryChoice([
|
|
17
|
+
"sm",
|
|
18
|
+
"md",
|
|
19
|
+
"lg",
|
|
20
|
+
"xl",
|
|
21
|
+
"full",
|
|
22
|
+
] as const),
|
|
23
|
+
},
|
|
24
|
+
args: {
|
|
25
|
+
headingLevel: 2,
|
|
26
|
+
isDismissable: true,
|
|
27
|
+
size: "md",
|
|
28
|
+
},
|
|
29
|
+
} satisfies Meta<typeof Modal>
|
|
30
|
+
|
|
31
|
+
export default meta
|
|
32
|
+
|
|
33
|
+
type Story = StoryObj<typeof meta>
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Every board here is triggers-plus-one-open-dialog rather than a
|
|
37
|
+
* grid, and that is the component rather than the stories being
|
|
38
|
+
* awkward: `showModal()` puts a dialog in the **top layer**, so two
|
|
39
|
+
* of them side by side is not a thing the platform can do. What the
|
|
40
|
+
* boards show instead is the real arrangement — a page, a scrim,
|
|
41
|
+
* and one dialog over both.
|
|
42
|
+
*
|
|
43
|
+
* `footer` is a function of `hide` because a dialog's own buttons
|
|
44
|
+
* are the normal way out of it, and they need the same `hide` the
|
|
45
|
+
* close button gets. One `useVisibility` per demo, no second source
|
|
46
|
+
* of truth.
|
|
47
|
+
*/
|
|
48
|
+
const ModalDemo = ({
|
|
49
|
+
buttonLabel,
|
|
50
|
+
children,
|
|
51
|
+
footer,
|
|
52
|
+
heading,
|
|
53
|
+
isDismissable = true,
|
|
54
|
+
size,
|
|
55
|
+
}: {
|
|
56
|
+
buttonLabel: string
|
|
57
|
+
children?: ReactNode
|
|
58
|
+
footer?: (hide: () => void) => ReactNode
|
|
59
|
+
heading: string
|
|
60
|
+
isDismissable?: boolean
|
|
61
|
+
size?: ModalSize
|
|
62
|
+
}): ReactNode => {
|
|
63
|
+
const { hide, isVisible, show } = useVisibility()
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<>
|
|
67
|
+
<Button appearance="soft" onClick={show} size="sm">
|
|
68
|
+
{buttonLabel}
|
|
69
|
+
</Button>
|
|
70
|
+
|
|
71
|
+
<Modal
|
|
72
|
+
footer={footer?.(hide)}
|
|
73
|
+
heading={heading}
|
|
74
|
+
isDismissable={isDismissable}
|
|
75
|
+
isVisible={isVisible}
|
|
76
|
+
onClose={hide}
|
|
77
|
+
size={size}
|
|
78
|
+
>
|
|
79
|
+
{children ?? (
|
|
80
|
+
<p className="text-content-secondary text-sm">
|
|
81
|
+
MakeMKV reported a read error on title 4. The
|
|
82
|
+
rip can continue with the remaining eight
|
|
83
|
+
titles, or stop here and keep what has
|
|
84
|
+
completed.
|
|
85
|
+
</p>
|
|
86
|
+
)}
|
|
87
|
+
</Modal>
|
|
88
|
+
</>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const Default: Story = {
|
|
93
|
+
args: {
|
|
94
|
+
heading: "Bay 3",
|
|
95
|
+
isVisible: false,
|
|
96
|
+
onClose: () => {},
|
|
97
|
+
},
|
|
98
|
+
render: () => (
|
|
99
|
+
<ModalDemo
|
|
100
|
+
buttonLabel="Show the read error"
|
|
101
|
+
heading="Read error on title 4"
|
|
102
|
+
/>
|
|
103
|
+
),
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const SIZES: ModalSize[] = ["sm", "md", "lg", "xl", "full"]
|
|
107
|
+
|
|
108
|
+
export const AllVariants: Story = {
|
|
109
|
+
args: {
|
|
110
|
+
heading: "Bay 3",
|
|
111
|
+
isVisible: false,
|
|
112
|
+
onClose: () => {},
|
|
113
|
+
},
|
|
114
|
+
render: () => (
|
|
115
|
+
<StoryRow>
|
|
116
|
+
{SIZES.map((size) => (
|
|
117
|
+
<ModalDemo
|
|
118
|
+
buttonLabel={`Open ${size}`}
|
|
119
|
+
heading={`Read error — ${size}`}
|
|
120
|
+
key={size}
|
|
121
|
+
size={size}
|
|
122
|
+
/>
|
|
123
|
+
))}
|
|
124
|
+
</StoryRow>
|
|
125
|
+
),
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export const AllStates: Story = {
|
|
129
|
+
args: {
|
|
130
|
+
heading: "Bay 3",
|
|
131
|
+
isVisible: false,
|
|
132
|
+
onClose: () => {},
|
|
133
|
+
},
|
|
134
|
+
render: () => (
|
|
135
|
+
<StoryRow>
|
|
136
|
+
<ModalDemo
|
|
137
|
+
buttonLabel="With a footer"
|
|
138
|
+
footer={(hide) => (
|
|
139
|
+
<>
|
|
140
|
+
<Button
|
|
141
|
+
appearance="soft"
|
|
142
|
+
intent="neutral"
|
|
143
|
+
onClick={hide}
|
|
144
|
+
size="sm"
|
|
145
|
+
>
|
|
146
|
+
Keep going
|
|
147
|
+
</Button>
|
|
148
|
+
|
|
149
|
+
<Button intent="danger" size="sm">
|
|
150
|
+
Stop the rip
|
|
151
|
+
</Button>
|
|
152
|
+
</>
|
|
153
|
+
)}
|
|
154
|
+
heading="Stop the rip?"
|
|
155
|
+
/>
|
|
156
|
+
|
|
157
|
+
<ModalDemo
|
|
158
|
+
buttonLabel="Not dismissable"
|
|
159
|
+
footer={(hide) => (
|
|
160
|
+
<>
|
|
161
|
+
<Button
|
|
162
|
+
appearance="soft"
|
|
163
|
+
intent="neutral"
|
|
164
|
+
onClick={hide}
|
|
165
|
+
size="sm"
|
|
166
|
+
>
|
|
167
|
+
Keep the titles
|
|
168
|
+
</Button>
|
|
169
|
+
|
|
170
|
+
<Button intent="danger" size="sm">
|
|
171
|
+
Erase and re-rip
|
|
172
|
+
</Button>
|
|
173
|
+
</>
|
|
174
|
+
)}
|
|
175
|
+
heading="This erases eight completed titles"
|
|
176
|
+
isDismissable={false}
|
|
177
|
+
/>
|
|
178
|
+
|
|
179
|
+
<ModalDemo
|
|
180
|
+
buttonLabel="Long body"
|
|
181
|
+
heading="MakeMKV log"
|
|
182
|
+
>
|
|
183
|
+
<div className="flex flex-col gap-2">
|
|
184
|
+
{Array.from(
|
|
185
|
+
{ length: 24 },
|
|
186
|
+
(_, index) =>
|
|
187
|
+
`14:0${index % 10}:22 title ${
|
|
188
|
+
index + 1
|
|
189
|
+
} AACS handshake complete, reading…`,
|
|
190
|
+
).map((line) => (
|
|
191
|
+
<p
|
|
192
|
+
className="font-mono text-content-secondary text-xs"
|
|
193
|
+
key={line}
|
|
194
|
+
>
|
|
195
|
+
{line}
|
|
196
|
+
</p>
|
|
197
|
+
))}
|
|
198
|
+
</div>
|
|
199
|
+
</ModalDemo>
|
|
200
|
+
</StoryRow>
|
|
201
|
+
),
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* A modal is the one surface in this library that is **not** a
|
|
206
|
+
* container-query citizen, so it gets no `ContainerBoard`. Its size
|
|
207
|
+
* is measured against the viewport by definition — it is the thing
|
|
208
|
+
* covering the viewport — and boarding it at three container widths
|
|
209
|
+
* would be measuring something that has no bearing on it.
|
|
210
|
+
*
|
|
211
|
+
* What does vary is the clamp: every size caps at `85dvh`, and
|
|
212
|
+
* `full` takes the lot. `dvh` rather than `vh` because on a phone
|
|
213
|
+
* the two differ by the address bar, and a dialog whose footer is
|
|
214
|
+
* underneath it is a dialog with no buttons.
|
|
215
|
+
*/
|
|
216
|
+
export const Responsive: Story = {
|
|
217
|
+
args: {
|
|
218
|
+
heading: "Bay 3",
|
|
219
|
+
isVisible: false,
|
|
220
|
+
onClose: () => {},
|
|
221
|
+
},
|
|
222
|
+
render: () => (
|
|
223
|
+
<ModalDemo
|
|
224
|
+
buttonLabel="Open full-bleed"
|
|
225
|
+
heading="MakeMKV log — full"
|
|
226
|
+
size="full"
|
|
227
|
+
>
|
|
228
|
+
<p className="text-content-secondary text-sm">
|
|
229
|
+
`full` is the kiosk and phone case: the dialog is
|
|
230
|
+
the screen, so there is nothing behind it worth
|
|
231
|
+
showing.
|
|
232
|
+
</p>
|
|
233
|
+
</ModalDemo>
|
|
234
|
+
),
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The keyboard contract, driven. `showModal()` supplies the focus
|
|
239
|
+
* trap and the focus restore; what this asserts is that the state
|
|
240
|
+
* layer never disagrees with the element — Escape routes through
|
|
241
|
+
* `onCancel` → `hide()` → the effect → `close()`, rather than the
|
|
242
|
+
* browser closing the dialog behind the app's back.
|
|
243
|
+
*/
|
|
244
|
+
export const Interactive: Story = {
|
|
245
|
+
args: {
|
|
246
|
+
heading: "Bay 3",
|
|
247
|
+
isVisible: false,
|
|
248
|
+
onClose: () => {},
|
|
249
|
+
},
|
|
250
|
+
render: () => (
|
|
251
|
+
<ModalDemo
|
|
252
|
+
buttonLabel="Stop the rip"
|
|
253
|
+
footer={(hide) => (
|
|
254
|
+
<>
|
|
255
|
+
<Button
|
|
256
|
+
appearance="soft"
|
|
257
|
+
intent="neutral"
|
|
258
|
+
onClick={hide}
|
|
259
|
+
size="sm"
|
|
260
|
+
>
|
|
261
|
+
Keep going
|
|
262
|
+
</Button>
|
|
263
|
+
|
|
264
|
+
<Button intent="danger" size="sm">
|
|
265
|
+
Stop it now
|
|
266
|
+
</Button>
|
|
267
|
+
</>
|
|
268
|
+
)}
|
|
269
|
+
heading="Stop the rip?"
|
|
270
|
+
/>
|
|
271
|
+
),
|
|
272
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { composeStories } from "@storybook/react"
|
|
2
|
+
import { expect, userEvent, waitFor } from "storybook/test"
|
|
3
|
+
import { test } from "vitest"
|
|
4
|
+
|
|
5
|
+
import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
|
|
6
|
+
import { mountStory } from "../mountStory.testHelpers.ts"
|
|
7
|
+
import { expectAgentDrivable } from "../testing/index.ts"
|
|
8
|
+
import * as stories from "./Modal.stories.tsx"
|
|
9
|
+
|
|
10
|
+
const {
|
|
11
|
+
AllStates,
|
|
12
|
+
AllVariants,
|
|
13
|
+
Default,
|
|
14
|
+
Interactive,
|
|
15
|
+
Responsive,
|
|
16
|
+
} = composeStories(stories)
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Escape, as far as a test can press it — and the limit is worth
|
|
20
|
+
* stating rather than working around quietly.
|
|
21
|
+
*
|
|
22
|
+
* `userEvent` is `@testing-library/user-event`, which dispatches
|
|
23
|
+
* **untrusted** events. The browser runs a default action only for
|
|
24
|
+
* trusted input, so a synthetic Escape keydown does not make a
|
|
25
|
+
* native `<dialog>` fire `cancel` — it does nothing at all. A test
|
|
26
|
+
* asserting "Escape did not close the non-dismissable dialog"
|
|
27
|
+
* against a synthetic keypress therefore passes for the wrong
|
|
28
|
+
* reason, forever, which is worse than not testing it.
|
|
29
|
+
*
|
|
30
|
+
* So the close request is dispatched where the browser would raise
|
|
31
|
+
* it. That splits the contract honestly: the keystroke → `cancel`
|
|
32
|
+
* half is the platform's and is not ours to test, and everything
|
|
33
|
+
* downstream of `cancel` — `preventDefault`, `hide()`, the effect,
|
|
34
|
+
* `close()`, focus restore — is ours and is asserted here.
|
|
35
|
+
*/
|
|
36
|
+
const requestClose = (dialog: HTMLElement) => {
|
|
37
|
+
dialog.dispatchEvent(
|
|
38
|
+
new Event("cancel", { cancelable: true }),
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test("the dialog is reachable from a scoped query", async () => {
|
|
43
|
+
const { canvas, canvasElement } =
|
|
44
|
+
await mountStory(Default)
|
|
45
|
+
|
|
46
|
+
await userEvent.click(
|
|
47
|
+
expectAgentDrivable(canvas, {
|
|
48
|
+
name: "Show the read error",
|
|
49
|
+
role: "button",
|
|
50
|
+
}),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
// Scoped to `canvas`, and that is the point of not portalling:
|
|
54
|
+
// the dialog paints in the top layer while staying inside the
|
|
55
|
+
// story root, so an agent's scoped query still reaches it.
|
|
56
|
+
const dialog = expectAgentDrivable(canvas, {
|
|
57
|
+
name: "Read error on title 4",
|
|
58
|
+
role: "dialog",
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
await expect(dialog).toHaveAttribute("open")
|
|
62
|
+
|
|
63
|
+
// The addon's own axe pass fired before any of this was clicked,
|
|
64
|
+
// so the *open* dialog audits itself.
|
|
65
|
+
await expectNoAxeViolations(canvasElement)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test("size is a real width, not a class that happens to be there", async () => {
|
|
69
|
+
const { canvas } = await mountStory(AllVariants)
|
|
70
|
+
|
|
71
|
+
await userEvent.click(
|
|
72
|
+
expectAgentDrivable(canvas, {
|
|
73
|
+
name: "Open lg",
|
|
74
|
+
role: "button",
|
|
75
|
+
}),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
const dialog = expectAgentDrivable(canvas, {
|
|
79
|
+
name: "Read error — lg",
|
|
80
|
+
role: "dialog",
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
await expect(
|
|
84
|
+
dialog.getBoundingClientRect().width,
|
|
85
|
+
).toBeGreaterThan(400)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test("a non-dismissable dialog cancels the close rather than ignoring it", async () => {
|
|
89
|
+
const { canvas } = await mountStory(AllStates)
|
|
90
|
+
|
|
91
|
+
await userEvent.click(
|
|
92
|
+
expectAgentDrivable(canvas, {
|
|
93
|
+
name: "Not dismissable",
|
|
94
|
+
role: "button",
|
|
95
|
+
}),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
const dialog = expectAgentDrivable(canvas, {
|
|
99
|
+
name: "This erases eight completed titles",
|
|
100
|
+
role: "dialog",
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// There is no Close button, and the close request is *cancelled*
|
|
104
|
+
// rather than ignored — letting the browser close it would leave
|
|
105
|
+
// `isVisible` true and the element shut, which is the desync the
|
|
106
|
+
// component exists to prevent.
|
|
107
|
+
await expect(
|
|
108
|
+
canvas.queryByRole("button", { name: "Close" }),
|
|
109
|
+
).toBeNull()
|
|
110
|
+
|
|
111
|
+
requestClose(dialog)
|
|
112
|
+
|
|
113
|
+
// `find*` is async, which is what gives React's update from that
|
|
114
|
+
// dispatch a chance to flush. If the request *had* got through,
|
|
115
|
+
// this query would fail rather than the assertion below passing by
|
|
116
|
+
// being too quick.
|
|
117
|
+
const keepButton = await canvas.findByRole("button", {
|
|
118
|
+
name: "Keep the titles",
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
await expect(dialog).toHaveAttribute("open")
|
|
122
|
+
|
|
123
|
+
// The differential half. Without it, "still open" would also be
|
|
124
|
+
// satisfied by a dialog nothing can close at all — a green
|
|
125
|
+
// assertion about a component that does not work.
|
|
126
|
+
await userEvent.click(keepButton)
|
|
127
|
+
|
|
128
|
+
await waitFor(() => {
|
|
129
|
+
expect(canvas.queryByRole("dialog")).toBeNull()
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test("a long body scrolls inside the clamp", async () => {
|
|
134
|
+
const { canvas, canvasElement } =
|
|
135
|
+
await mountStory(AllStates)
|
|
136
|
+
|
|
137
|
+
await userEvent.click(
|
|
138
|
+
canvas.getByRole("button", { name: "Long body" }),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
const log = expectAgentDrivable(canvas, {
|
|
142
|
+
name: "MakeMKV log",
|
|
143
|
+
role: "dialog",
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// The body scrolls, the header and footer do not, and the dialog
|
|
147
|
+
// stops at `85dvh` rather than running off the screen.
|
|
148
|
+
await expect(
|
|
149
|
+
log.getBoundingClientRect().height,
|
|
150
|
+
).toBeLessThan(globalThis.innerHeight)
|
|
151
|
+
|
|
152
|
+
await expect(
|
|
153
|
+
canvas.getByText(/title 24/),
|
|
154
|
+
).toBeInTheDocument()
|
|
155
|
+
|
|
156
|
+
// The case that would trip `scrollable-region-focusable` if the
|
|
157
|
+
// body scrolled with nothing inside it to focus.
|
|
158
|
+
await expectNoAxeViolations(canvasElement)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test("the scrim is the token, not Chromium's default backdrop", async () => {
|
|
162
|
+
const { canvas } = await mountStory(Responsive)
|
|
163
|
+
|
|
164
|
+
await userEvent.click(
|
|
165
|
+
expectAgentDrivable(canvas, {
|
|
166
|
+
name: "Open full-bleed",
|
|
167
|
+
role: "button",
|
|
168
|
+
}),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
const dialog = expectAgentDrivable(canvas, {
|
|
172
|
+
name: "MakeMKV log — full",
|
|
173
|
+
role: "dialog",
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
await expect(
|
|
177
|
+
dialog.getBoundingClientRect().height,
|
|
178
|
+
).toBeGreaterThan(globalThis.innerHeight * 0.95)
|
|
179
|
+
|
|
180
|
+
// `::backdrop` inheriting custom properties from its originating
|
|
181
|
+
// element is the half that could silently not work.
|
|
182
|
+
//
|
|
183
|
+
// Compared against the token's *resolved* value rather than
|
|
184
|
+
// against "not transparent", which M4 learned the hard way:
|
|
185
|
+
// Chromium's own `::backdrop` is `rgba(0, 0, 0, 0.1)`, so the
|
|
186
|
+
// loose version of this assertion stayed green against a token
|
|
187
|
+
// build where `bg-scrim` generated no CSS at all.
|
|
188
|
+
//
|
|
189
|
+
// The probe is how the two become comparable — the same `var()`
|
|
190
|
+
// put through `background-color` comes back in the identical
|
|
191
|
+
// serialisation the backdrop uses.
|
|
192
|
+
const probe = document.createElement("div")
|
|
193
|
+
|
|
194
|
+
probe.style.backgroundColor = "var(--color-scrim)"
|
|
195
|
+
|
|
196
|
+
document.body.append(probe)
|
|
197
|
+
|
|
198
|
+
const expectedScrim =
|
|
199
|
+
globalThis.getComputedStyle(probe).backgroundColor
|
|
200
|
+
|
|
201
|
+
probe.remove()
|
|
202
|
+
|
|
203
|
+
await expect(expectedScrim).not.toBe("rgba(0, 0, 0, 0)")
|
|
204
|
+
|
|
205
|
+
await expect(
|
|
206
|
+
globalThis.getComputedStyle(dialog, "::backdrop")
|
|
207
|
+
.backgroundColor,
|
|
208
|
+
).toBe(expectedScrim)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* `showModal()` supplies the focus trap and the focus restore; what
|
|
213
|
+
* this asserts is that the state layer never disagrees with the
|
|
214
|
+
* element — Escape routes through `onCancel` → `hide()` → the
|
|
215
|
+
* effect → `close()`, rather than the browser closing the dialog
|
|
216
|
+
* behind the app's back.
|
|
217
|
+
*/
|
|
218
|
+
test("focus is trapped inside, and restored to the trigger on close", async () => {
|
|
219
|
+
const { canvas } = await mountStory(Interactive)
|
|
220
|
+
|
|
221
|
+
const trigger = expectAgentDrivable(canvas, {
|
|
222
|
+
name: "Stop the rip",
|
|
223
|
+
role: "button",
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
await userEvent.click(trigger)
|
|
227
|
+
|
|
228
|
+
const dialog = expectAgentDrivable(canvas, {
|
|
229
|
+
name: "Stop the rip?",
|
|
230
|
+
role: "dialog",
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
// The trap, from the platform: focus is inside the dialog, and the
|
|
234
|
+
// trigger behind it is `inert` and unreachable.
|
|
235
|
+
await expect(dialog).toContainElement(
|
|
236
|
+
document.activeElement as HTMLElement,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
requestClose(dialog)
|
|
240
|
+
|
|
241
|
+
await waitFor(() => {
|
|
242
|
+
expect(canvas.queryByRole("dialog")).toBeNull()
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
// Focus restored to what opened it — also the platform's, and the
|
|
246
|
+
// thing hand-rolled modals in the fleet lose.
|
|
247
|
+
await expect(document.activeElement).toBe(trigger)
|
|
248
|
+
})
|