@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,116 @@
|
|
|
1
|
+
import type { ConnectionStatus } from "@charcuterie/logic"
|
|
2
|
+
import type {
|
|
3
|
+
ComponentPropsWithRef,
|
|
4
|
+
ReactNode,
|
|
5
|
+
} from "react"
|
|
6
|
+
|
|
7
|
+
import { DOT_SIZE_CLASS } from "../controlStyles.ts"
|
|
8
|
+
import {
|
|
9
|
+
INTENT_CONTENT_CLASS,
|
|
10
|
+
INTENT_SOLID_FILL_CLASS,
|
|
11
|
+
} from "../intentStyles.ts"
|
|
12
|
+
import {
|
|
13
|
+
getConnectionIntent,
|
|
14
|
+
getConnectionLabel,
|
|
15
|
+
getIsConnectionBusy,
|
|
16
|
+
} from "../statusIntent.ts"
|
|
17
|
+
import { toClassName } from "../toClassName.ts"
|
|
18
|
+
import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx"
|
|
19
|
+
|
|
20
|
+
export type LiveStatusIndicatorSize =
|
|
21
|
+
keyof typeof DOT_SIZE_CLASS
|
|
22
|
+
|
|
23
|
+
export type LiveStatusIndicatorProps = Omit<
|
|
24
|
+
ComponentPropsWithRef<"span">,
|
|
25
|
+
"children"
|
|
26
|
+
> & {
|
|
27
|
+
/** Extra context, announced after the label. "3 of 4 bays." */
|
|
28
|
+
detail?: string
|
|
29
|
+
isLabelVisible?: boolean
|
|
30
|
+
/** Overrides the machine's own wording. Rarely wanted. */
|
|
31
|
+
label?: string
|
|
32
|
+
size?: LiveStatusIndicatorSize
|
|
33
|
+
status: ConnectionStatus
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Four repos, four different implementations, and **it is the thing
|
|
38
|
+
* users read most** — a wall-mounted panel showing stale data with a
|
|
39
|
+
* green dot is worse than one showing nothing.
|
|
40
|
+
*
|
|
41
|
+
* The second reason it is P0: all four collapse `connecting` and
|
|
42
|
+
* `reconnecting` into one state, which reads wrong on exactly the
|
|
43
|
+
* link that needs it. A user who has seen data wants "lost it,
|
|
44
|
+
* getting it back" (amber, moving), not the cold-start spinner they
|
|
45
|
+
* already sat through (blue) and not a dead link (red). That
|
|
46
|
+
* distinction is why `connectionTransitions` is one of the two
|
|
47
|
+
* machines `@charcuterie/logic` ships rather than something each app
|
|
48
|
+
* defines, and `getConnectionIntent` / `getConnectionLabel` are
|
|
49
|
+
* exhaustive switches over it — add a fifth state and this component
|
|
50
|
+
* stops compiling instead of rendering a grey dot.
|
|
51
|
+
*
|
|
52
|
+
* `role="status"` + `aria-live="polite"` on the wrapper, so a
|
|
53
|
+
* transition is announced without interrupting whatever the user is
|
|
54
|
+
* doing. `aria-live="assertive"` would be wrong here even for
|
|
55
|
+
* `disconnected`: it cuts off the sentence in progress, and the dot
|
|
56
|
+
* is not going anywhere.
|
|
57
|
+
*/
|
|
58
|
+
export const LiveStatusIndicator = ({
|
|
59
|
+
className,
|
|
60
|
+
detail,
|
|
61
|
+
isLabelVisible = true,
|
|
62
|
+
label,
|
|
63
|
+
size = "md",
|
|
64
|
+
status,
|
|
65
|
+
...spanProps
|
|
66
|
+
}: LiveStatusIndicatorProps): ReactNode => {
|
|
67
|
+
const intent = getConnectionIntent(status)
|
|
68
|
+
|
|
69
|
+
const statusLabel = label ?? getConnectionLabel(status)
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<span
|
|
73
|
+
{...spanProps}
|
|
74
|
+
// Named as well as live, for the same reason as `Spinner`: a
|
|
75
|
+
// `status` region gets no name from its content, so this is
|
|
76
|
+
// what makes `getByRole("status", { name: "Connected" })` — the
|
|
77
|
+
// query an agent will actually write — resolve.
|
|
78
|
+
aria-label={statusLabel}
|
|
79
|
+
aria-live="polite"
|
|
80
|
+
className={toClassName(
|
|
81
|
+
"inline-flex items-center gap-2 text-sm",
|
|
82
|
+
INTENT_CONTENT_CLASS[intent],
|
|
83
|
+
className,
|
|
84
|
+
)}
|
|
85
|
+
role="status"
|
|
86
|
+
// Not an ARIA attribute — a hook for a consumer's own CSS and
|
|
87
|
+
// for a Playwright assertion that wants the state rather than
|
|
88
|
+
// the wording, which survives translation.
|
|
89
|
+
data-status={status}
|
|
90
|
+
>
|
|
91
|
+
<span
|
|
92
|
+
aria-hidden="true"
|
|
93
|
+
className={toClassName(
|
|
94
|
+
"inline-block shrink-0 rounded-full",
|
|
95
|
+
DOT_SIZE_CLASS[size],
|
|
96
|
+
INTENT_SOLID_FILL_CLASS[intent],
|
|
97
|
+
// Only the two in-flight states move. A pulsing dot that
|
|
98
|
+
// never stops is the fleet's current "is it alive?"
|
|
99
|
+
// problem in a new costume.
|
|
100
|
+
getIsConnectionBusy(status) &&
|
|
101
|
+
"charcuterie-pulse",
|
|
102
|
+
)}
|
|
103
|
+
/>
|
|
104
|
+
|
|
105
|
+
{isLabelVisible ? (
|
|
106
|
+
<span>{statusLabel}</span>
|
|
107
|
+
) : (
|
|
108
|
+
<VisuallyHidden>{statusLabel}</VisuallyHidden>
|
|
109
|
+
)}
|
|
110
|
+
|
|
111
|
+
{detail ? (
|
|
112
|
+
<span className="text-content-muted">{detail}</span>
|
|
113
|
+
) : null}
|
|
114
|
+
</span>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as MediaTileStories from "./MediaTile.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={MediaTileStories} />
|
|
6
|
+
|
|
7
|
+
# MediaTile
|
|
8
|
+
|
|
9
|
+
Three repos, and shipping one of these is what makes plex-channels, image-viewer, and
|
|
10
|
+
gallery-downloader look like one family instead of three.
|
|
11
|
+
|
|
12
|
+
<Canvas of={MediaTileStories.Grid} />
|
|
13
|
+
|
|
14
|
+
## Props
|
|
15
|
+
|
|
16
|
+
<Controls />
|
|
17
|
+
|
|
18
|
+
## The three states no hand-rolled version models
|
|
19
|
+
|
|
20
|
+
| State | What it does | Why |
|
|
21
|
+
| --- | --- | --- |
|
|
22
|
+
| loading | `Skeleton` at the tile's exact aspect ratio | A 200-poster grid that reflows on load loses the user's place |
|
|
23
|
+
| error | Keeps the box, keeps the name (`role="img"` with the same `alt`) | A broken `<img>` collapses to a glyph and takes the row height with it |
|
|
24
|
+
| cached | Checks `complete`/`naturalWidth` on mount and on every `src` change | A `complete` image never fires `load`, so an `onLoad`-only tile shimmers over a perfectly good poster forever |
|
|
25
|
+
|
|
26
|
+
Those are a `useStatus` machine rather than a `useState<string>`, which is what makes
|
|
27
|
+
`loaded → error` impossible instead of merely unlikely — see `mediaStatus.ts`. A browser
|
|
28
|
+
does not fire `error` on an image it has already decoded, so an app that thinks it saw that
|
|
29
|
+
is misreading its own event wiring, and the machine throws rather than flickering a fallback
|
|
30
|
+
over a good poster.
|
|
31
|
+
|
|
32
|
+
<Canvas of={MediaTileStories.AllStates} />
|
|
33
|
+
|
|
34
|
+
## Sizing
|
|
35
|
+
|
|
36
|
+
The tile is a container query (the caption's type steps up past `--cq-sm`), which means it
|
|
37
|
+
may not be sized by its own contents: put it in a grid track or give it a definite inline
|
|
38
|
+
size. In a shrink-to-fit parent it collapses to min-content — the same constraint `Card`
|
|
39
|
+
carries, and for the same reason.
|
|
40
|
+
|
|
41
|
+
## Names
|
|
42
|
+
|
|
43
|
+
`alt` is required. When `href` is set, the link carries `aria-label={title}` and the image
|
|
44
|
+
goes `alt=""` — an agent writing `getByRole("link", { name: "Blade Runner" })` should not
|
|
45
|
+
have to know whether a subtitle happened to be rendered, and two names for one link is a
|
|
46
|
+
screen reader reading it twice.
|
|
47
|
+
|
|
48
|
+
The caption's type steps up past `--cq-sm`, so a tile in a dense 8-across grid and the same
|
|
49
|
+
tile in a 3-across one are both legible.
|
|
50
|
+
|
|
51
|
+
<Canvas of={MediaTileStories.Responsive} />
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
|
+
|
|
3
|
+
import { Badge } from "../Badge/Badge.tsx"
|
|
4
|
+
import {
|
|
5
|
+
ContainerBoard,
|
|
6
|
+
StoryCell,
|
|
7
|
+
StoryGrid,
|
|
8
|
+
StorySection,
|
|
9
|
+
} from "../board.storyHelpers.tsx"
|
|
10
|
+
import { MediaTile } from "./MediaTile.tsx"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* An inline SVG rather than a fixture file or a placeholder service:
|
|
14
|
+
* it decodes synchronously, needs no network, and makes the
|
|
15
|
+
* `loaded` state deterministic in CI — which matters because the
|
|
16
|
+
* *cached* path is one of the three states this component exists to
|
|
17
|
+
* get right.
|
|
18
|
+
*/
|
|
19
|
+
const toPosterSrc = (label: string) =>
|
|
20
|
+
`data:image/svg+xml;utf8,${encodeURIComponent(
|
|
21
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 300"><rect width="200" height="300" fill="#3E38C4"/><text x="100" y="150" fill="#FFFFFF" font-family="sans-serif" font-size="18" text-anchor="middle">${label}</text></svg>`,
|
|
22
|
+
)}`
|
|
23
|
+
|
|
24
|
+
/** 404s, which is what makes the error state real rather than mocked. */
|
|
25
|
+
const MISSING_SRC = "/charcuterie-missing-poster.png"
|
|
26
|
+
|
|
27
|
+
const meta = {
|
|
28
|
+
title: "Components/MediaTile",
|
|
29
|
+
component: MediaTile,
|
|
30
|
+
parameters: { layout: "padded" },
|
|
31
|
+
args: { aspectRatio: "poster" },
|
|
32
|
+
} satisfies Meta<typeof MediaTile>
|
|
33
|
+
|
|
34
|
+
export default meta
|
|
35
|
+
|
|
36
|
+
type Story = StoryObj<typeof meta>
|
|
37
|
+
|
|
38
|
+
export const Default: Story = {
|
|
39
|
+
args: {
|
|
40
|
+
alt: "Blade Runner (1982) poster",
|
|
41
|
+
src: toPosterSrc("Blade Runner"),
|
|
42
|
+
subtitle: "1982 · 4K remaster",
|
|
43
|
+
title: "Blade Runner",
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const AllVariants: Story = {
|
|
48
|
+
args: { alt: "Poster", title: "Blade Runner" },
|
|
49
|
+
render: () => (
|
|
50
|
+
<StorySection title="Three trims. 2:3 is the poster standard — Plex, Kavita, and every physical sleeve in the collection.">
|
|
51
|
+
<StoryGrid columns={3}>
|
|
52
|
+
<StoryCell align="stretch" label="poster (2:3)">
|
|
53
|
+
<MediaTile
|
|
54
|
+
alt="Blade Runner (1982) poster"
|
|
55
|
+
src={toPosterSrc("2:3")}
|
|
56
|
+
subtitle="1982"
|
|
57
|
+
title="Blade Runner"
|
|
58
|
+
/>
|
|
59
|
+
</StoryCell>
|
|
60
|
+
|
|
61
|
+
<StoryCell align="stretch" label="square">
|
|
62
|
+
<MediaTile
|
|
63
|
+
alt="Kind of Blue album art"
|
|
64
|
+
aspectRatio="square"
|
|
65
|
+
src={toPosterSrc("1:1")}
|
|
66
|
+
subtitle="Miles Davis"
|
|
67
|
+
title="Kind of Blue"
|
|
68
|
+
/>
|
|
69
|
+
</StoryCell>
|
|
70
|
+
|
|
71
|
+
<StoryCell align="stretch" label="video (16:9)">
|
|
72
|
+
<MediaTile
|
|
73
|
+
alt="Episode thumbnail"
|
|
74
|
+
aspectRatio="video"
|
|
75
|
+
src={toPosterSrc("16:9")}
|
|
76
|
+
subtitle="S01E04"
|
|
77
|
+
title="The Constant"
|
|
78
|
+
/>
|
|
79
|
+
</StoryCell>
|
|
80
|
+
</StoryGrid>
|
|
81
|
+
</StorySection>
|
|
82
|
+
),
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The three states are the component, and no hand-rolled version in
|
|
87
|
+
* the fleet models more than one of them.
|
|
88
|
+
*/
|
|
89
|
+
export const AllStates: Story = {
|
|
90
|
+
args: { alt: "Poster", title: "Blade Runner" },
|
|
91
|
+
render: () => (
|
|
92
|
+
<StoryGrid columns={4}>
|
|
93
|
+
<StoryCell align="stretch" label="loaded">
|
|
94
|
+
<MediaTile
|
|
95
|
+
alt="Blade Runner (1982) poster"
|
|
96
|
+
src={toPosterSrc("Blade Runner")}
|
|
97
|
+
subtitle="1982 · 4K remaster"
|
|
98
|
+
title="Blade Runner"
|
|
99
|
+
/>
|
|
100
|
+
</StoryCell>
|
|
101
|
+
|
|
102
|
+
<StoryCell
|
|
103
|
+
align="stretch"
|
|
104
|
+
label="loading — skeleton holds the box"
|
|
105
|
+
>
|
|
106
|
+
<MediaTile
|
|
107
|
+
alt="Poster loading"
|
|
108
|
+
subtitle="waiting for the image"
|
|
109
|
+
title="Still loading"
|
|
110
|
+
/>
|
|
111
|
+
</StoryCell>
|
|
112
|
+
|
|
113
|
+
<StoryCell
|
|
114
|
+
align="stretch"
|
|
115
|
+
label="error — box and name survive"
|
|
116
|
+
>
|
|
117
|
+
<MediaTile
|
|
118
|
+
alt="Dune (2021) poster"
|
|
119
|
+
src={MISSING_SRC}
|
|
120
|
+
subtitle="2021"
|
|
121
|
+
title="Dune"
|
|
122
|
+
/>
|
|
123
|
+
</StoryCell>
|
|
124
|
+
|
|
125
|
+
<StoryCell align="stretch" label="link + badge">
|
|
126
|
+
<MediaTile
|
|
127
|
+
alt="Blade Runner (1982) poster"
|
|
128
|
+
badge={
|
|
129
|
+
<Badge intent="success" size="sm">
|
|
130
|
+
4K
|
|
131
|
+
</Badge>
|
|
132
|
+
}
|
|
133
|
+
href="#blade-runner"
|
|
134
|
+
src={toPosterSrc("Blade Runner")}
|
|
135
|
+
subtitle="1982 · 4K remaster"
|
|
136
|
+
title="Blade Runner"
|
|
137
|
+
/>
|
|
138
|
+
</StoryCell>
|
|
139
|
+
</StoryGrid>
|
|
140
|
+
),
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The caption's type steps up past `--cq-sm`, so a tile in a dense
|
|
145
|
+
* 8-across grid and the same tile in a 3-across one are legible at
|
|
146
|
+
* both — the intermediate widths where the fleet's poster grids look
|
|
147
|
+
* wrong today.
|
|
148
|
+
*/
|
|
149
|
+
export const Responsive: Story = {
|
|
150
|
+
args: { alt: "Poster", title: "Blade Runner" },
|
|
151
|
+
render: () => (
|
|
152
|
+
<ContainerBoard>
|
|
153
|
+
<MediaTile
|
|
154
|
+
alt="Blade Runner (1982) poster"
|
|
155
|
+
src={toPosterSrc("Blade Runner")}
|
|
156
|
+
subtitle="1982 · 4K remaster"
|
|
157
|
+
title="Blade Runner"
|
|
158
|
+
/>
|
|
159
|
+
</ContainerBoard>
|
|
160
|
+
),
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const Grid: Story = {
|
|
164
|
+
args: { alt: "Poster", title: "Blade Runner" },
|
|
165
|
+
render: () => (
|
|
166
|
+
// Two elements, not one: the container and the grid that queries
|
|
167
|
+
// it **must** be different, because a container query matches
|
|
168
|
+
// descendants only. Collapsed into one `<div>` this compiles,
|
|
169
|
+
// generates real CSS, and silently stays two columns — which on a
|
|
170
|
+
// desktop is four 700px posters.
|
|
171
|
+
<div className="@container">
|
|
172
|
+
<div className="grid grid-cols-2 gap-4 cq-sm:grid-cols-3 cq-md:grid-cols-4 cq-lg:grid-cols-6">
|
|
173
|
+
{[
|
|
174
|
+
"Blade Runner",
|
|
175
|
+
"Dune",
|
|
176
|
+
"Arrival",
|
|
177
|
+
"Solaris",
|
|
178
|
+
"Stalker",
|
|
179
|
+
"Annihilation",
|
|
180
|
+
].map((title, index) => (
|
|
181
|
+
<MediaTile
|
|
182
|
+
alt={`${title} poster`}
|
|
183
|
+
href={`#${title.toLowerCase().replace(" ", "-")}`}
|
|
184
|
+
key={title}
|
|
185
|
+
src={
|
|
186
|
+
index === 1 ? MISSING_SRC : toPosterSrc(title)
|
|
187
|
+
}
|
|
188
|
+
subtitle="Science fiction"
|
|
189
|
+
title={title}
|
|
190
|
+
/>
|
|
191
|
+
))}
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
),
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The linked tile's contract: the accessible name is exactly the
|
|
199
|
+
* title — not the title plus the subtitle plus the alt text — and
|
|
200
|
+
* Tab reaches it. `getByRole("link", { name: "Blade Runner" })` is
|
|
201
|
+
* what an agent will actually write.
|
|
202
|
+
*/
|
|
203
|
+
export const Interactive: Story = {
|
|
204
|
+
args: {
|
|
205
|
+
alt: "Blade Runner (1982) poster",
|
|
206
|
+
href: "#blade-runner",
|
|
207
|
+
src: toPosterSrc("Blade Runner"),
|
|
208
|
+
subtitle: "1982 · 4K remaster",
|
|
209
|
+
title: "Blade Runner",
|
|
210
|
+
},
|
|
211
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { composeStories } from "@storybook/react"
|
|
2
|
+
import { expect, userEvent } from "storybook/test"
|
|
3
|
+
import { test } from "vitest"
|
|
4
|
+
|
|
5
|
+
import { mountStory } from "../mountStory.testHelpers.ts"
|
|
6
|
+
import { expectAgentDrivable } from "../testing/index.ts"
|
|
7
|
+
import * as stories from "./MediaTile.stories.tsx"
|
|
8
|
+
|
|
9
|
+
const { Default, Interactive } = composeStories(stories)
|
|
10
|
+
|
|
11
|
+
test("an unlinked tile's poster carries the name", async () => {
|
|
12
|
+
const { canvas } = await mountStory(Default)
|
|
13
|
+
|
|
14
|
+
expectAgentDrivable(canvas, {
|
|
15
|
+
name: "Blade Runner (1982) poster",
|
|
16
|
+
role: "img",
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The linked tile's contract: the accessible name is exactly the
|
|
22
|
+
* title — not the title plus the subtitle plus the alt text — and
|
|
23
|
+
* Tab reaches it. `getByRole("link", { name: "Blade Runner" })` is
|
|
24
|
+
* what an agent will actually write.
|
|
25
|
+
*/
|
|
26
|
+
test("a linked tile is one link named for its title", async () => {
|
|
27
|
+
const { canvas } = await mountStory(Interactive)
|
|
28
|
+
|
|
29
|
+
const link = expectAgentDrivable(canvas, {
|
|
30
|
+
name: "Blade Runner",
|
|
31
|
+
role: "link",
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
await userEvent.tab()
|
|
35
|
+
|
|
36
|
+
await expect(link).toHaveFocus()
|
|
37
|
+
|
|
38
|
+
// The image inside a linked tile is `alt=""` on purpose: two names
|
|
39
|
+
// for one link is a screen reader reading it twice.
|
|
40
|
+
await expect(canvas.queryAllByRole("img")).toHaveLength(0)
|
|
41
|
+
})
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { useStatus } from "@charcuterie/logic"
|
|
2
|
+
import type {
|
|
3
|
+
ComponentPropsWithRef,
|
|
4
|
+
ReactNode,
|
|
5
|
+
} from "react"
|
|
6
|
+
import { useEffect, useRef } from "react"
|
|
7
|
+
|
|
8
|
+
import { Skeleton } from "../Skeleton/Skeleton.tsx"
|
|
9
|
+
import { toClassName } from "../toClassName.ts"
|
|
10
|
+
import { mediaTransitions } from "./mediaStatus.ts"
|
|
11
|
+
|
|
12
|
+
export type MediaTileRatio = "poster" | "square" | "video"
|
|
13
|
+
|
|
14
|
+
export type MediaTileProps = Omit<
|
|
15
|
+
ComponentPropsWithRef<"figure">,
|
|
16
|
+
"children"
|
|
17
|
+
> & {
|
|
18
|
+
/**
|
|
19
|
+
* Required. The one thing three hand-rolled poster grids all
|
|
20
|
+
* omit — and a 200-tile page of unnamed images is unnavigable
|
|
21
|
+
* rather than merely imperfect.
|
|
22
|
+
*/
|
|
23
|
+
alt: string
|
|
24
|
+
aspectRatio?: MediaTileRatio
|
|
25
|
+
/** A `Badge`, usually. Pinned inside the media box. */
|
|
26
|
+
badge?: ReactNode
|
|
27
|
+
/** Shown in place of the image when it fails. */
|
|
28
|
+
fallback?: ReactNode
|
|
29
|
+
href?: string
|
|
30
|
+
src?: string
|
|
31
|
+
subtitle?: ReactNode
|
|
32
|
+
title: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const RATIO_CLASS: Record<MediaTileRatio, string> = {
|
|
36
|
+
// 2:3 is the standard poster trim, which is what Plex, Kavita, and
|
|
37
|
+
// every physical sleeve in the collection actually are.
|
|
38
|
+
poster: "aspect-[2/3]",
|
|
39
|
+
square: "aspect-square",
|
|
40
|
+
video: "aspect-video",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Three repos, and shipping one of these is what makes
|
|
45
|
+
* plex-channels, image-viewer, and gallery-downloader look like one
|
|
46
|
+
* family instead of three.
|
|
47
|
+
*
|
|
48
|
+
* The interesting part is not the layout, it is the three states an
|
|
49
|
+
* image has and the fact that no hand-rolled version models them:
|
|
50
|
+
*
|
|
51
|
+
* - **Loading** shows a `Skeleton` at the tile's exact aspect ratio,
|
|
52
|
+
* so the grid does not reflow when 200 posters land. Layout shift
|
|
53
|
+
* is the thing users notice in these apps today.
|
|
54
|
+
* - **Error** keeps the tile's box and its name. An `<img>` with a
|
|
55
|
+
* broken `src` collapses to a glyph and takes the row's height
|
|
56
|
+
* with it; the fallback here is a `role="img"` carrying the same
|
|
57
|
+
* `alt`, so the tile stays findable and the grid stays a grid.
|
|
58
|
+
* - **Cached** is the case that bites: a `complete` image never
|
|
59
|
+
* fires `load`, so a naive `onLoad`-only implementation leaves a
|
|
60
|
+
* shimmering skeleton over a perfectly rendered poster forever.
|
|
61
|
+
* The effect below checks `complete`/`naturalWidth` on mount and
|
|
62
|
+
* on every `src` change.
|
|
63
|
+
*
|
|
64
|
+
* Those states are a `useStatus` machine rather than a
|
|
65
|
+
* `useState<string>`, which is what makes `loaded → error`
|
|
66
|
+
* impossible instead of merely unlikely — see `mediaStatus.ts`.
|
|
67
|
+
*/
|
|
68
|
+
export const MediaTile = ({
|
|
69
|
+
alt,
|
|
70
|
+
aspectRatio = "poster",
|
|
71
|
+
badge,
|
|
72
|
+
className,
|
|
73
|
+
fallback,
|
|
74
|
+
href,
|
|
75
|
+
src,
|
|
76
|
+
subtitle,
|
|
77
|
+
title,
|
|
78
|
+
...figureProps
|
|
79
|
+
}: MediaTileProps): ReactNode => {
|
|
80
|
+
const imageRef = useRef<HTMLImageElement>(null)
|
|
81
|
+
|
|
82
|
+
const { can, reset, status, transitionTo } = useStatus({
|
|
83
|
+
initialState: "loading",
|
|
84
|
+
transitions: mediaTransitions,
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
// A new `src` is a new load, including after a failure.
|
|
89
|
+
reset()
|
|
90
|
+
|
|
91
|
+
const image = imageRef.current
|
|
92
|
+
|
|
93
|
+
if (!image?.complete) {
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// `complete` with no intrinsic width is a *failed* cached
|
|
98
|
+
// load, not a successful one — the distinction browsers only
|
|
99
|
+
// expose this way.
|
|
100
|
+
if (image.naturalWidth > 0) {
|
|
101
|
+
if (can("loaded")) {
|
|
102
|
+
transitionTo("loaded")
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (can("error")) {
|
|
109
|
+
transitionTo("error")
|
|
110
|
+
}
|
|
111
|
+
}, [can, reset, transitionTo])
|
|
112
|
+
|
|
113
|
+
const media = (
|
|
114
|
+
<div
|
|
115
|
+
className={toClassName(
|
|
116
|
+
"relative overflow-hidden rounded-md bg-surface-sunken",
|
|
117
|
+
RATIO_CLASS[aspectRatio],
|
|
118
|
+
)}
|
|
119
|
+
>
|
|
120
|
+
{status === "error" ? (
|
|
121
|
+
<div
|
|
122
|
+
// Unconditionally an `img` carrying the same `alt`, even
|
|
123
|
+
// inside a link. The link's own `aria-label` is a name from
|
|
124
|
+
// the author and takes precedence, so this cannot
|
|
125
|
+
// double-name it — and a conditional role would leave the
|
|
126
|
+
// failure case nameless in exactly the arrangement (a
|
|
127
|
+
// poster grid of links) where it happens most.
|
|
128
|
+
aria-label={alt}
|
|
129
|
+
className="flex size-full flex-col items-center justify-center gap-1 p-2 text-center text-content-muted text-xs"
|
|
130
|
+
role="img"
|
|
131
|
+
>
|
|
132
|
+
{/* Words, not a glyph. The library ships zero SVGs by
|
|
133
|
+
rule, and a symbol character is not a safe substitute:
|
|
134
|
+
`▨` depends on the *system* having a font that covers
|
|
135
|
+
it, which the kiosk Pis and this repo's headless
|
|
136
|
+
Chromium do not — so the honest default is text, and an
|
|
137
|
+
app with an icon set passes `fallback`. */}
|
|
138
|
+
{fallback ?? <span>Image unavailable</span>}
|
|
139
|
+
</div>
|
|
140
|
+
) : (
|
|
141
|
+
<>
|
|
142
|
+
{status === "loading" ? (
|
|
143
|
+
<Skeleton className="absolute inset-0 size-full" />
|
|
144
|
+
) : null}
|
|
145
|
+
|
|
146
|
+
<img
|
|
147
|
+
alt={href ? "" : alt}
|
|
148
|
+
className={toClassName(
|
|
149
|
+
"size-full object-cover transition-opacity duration-(--duration-normal) ease-standard",
|
|
150
|
+
status === "loaded"
|
|
151
|
+
? "opacity-100"
|
|
152
|
+
: "opacity-0",
|
|
153
|
+
)}
|
|
154
|
+
decoding="async"
|
|
155
|
+
loading="lazy"
|
|
156
|
+
onError={() => {
|
|
157
|
+
if (can("error")) {
|
|
158
|
+
transitionTo("error")
|
|
159
|
+
}
|
|
160
|
+
}}
|
|
161
|
+
onLoad={() => {
|
|
162
|
+
if (can("loaded")) {
|
|
163
|
+
transitionTo("loaded")
|
|
164
|
+
}
|
|
165
|
+
}}
|
|
166
|
+
ref={imageRef}
|
|
167
|
+
src={src}
|
|
168
|
+
/>
|
|
169
|
+
</>
|
|
170
|
+
)}
|
|
171
|
+
|
|
172
|
+
{badge ? (
|
|
173
|
+
<div className="absolute top-2 start-2">
|
|
174
|
+
{badge}
|
|
175
|
+
</div>
|
|
176
|
+
) : null}
|
|
177
|
+
</div>
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
const caption = (
|
|
181
|
+
<figcaption className="flex flex-col gap-0.5 pt-2">
|
|
182
|
+
<span className="truncate font-medium text-content-primary text-sm cq-sm:text-md">
|
|
183
|
+
{title}
|
|
184
|
+
</span>
|
|
185
|
+
|
|
186
|
+
{subtitle ? (
|
|
187
|
+
<span className="truncate text-content-muted text-xs">
|
|
188
|
+
{subtitle}
|
|
189
|
+
</span>
|
|
190
|
+
) : null}
|
|
191
|
+
</figcaption>
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<figure
|
|
196
|
+
{...figureProps}
|
|
197
|
+
className={toClassName(
|
|
198
|
+
"@container flex flex-col",
|
|
199
|
+
className,
|
|
200
|
+
)}
|
|
201
|
+
>
|
|
202
|
+
{href ? (
|
|
203
|
+
// `aria-label` rather than the caption text, so the link's
|
|
204
|
+
// name is exactly the title — an agent matching
|
|
205
|
+
// `getByRole("link", { name: "Blade Runner" })` should not
|
|
206
|
+
// have to know whether a subtitle happened to be rendered.
|
|
207
|
+
// The `<img>` inside goes `alt=""` for the same reason: two
|
|
208
|
+
// names for one link is a screen reader reading it twice.
|
|
209
|
+
<a
|
|
210
|
+
aria-label={title}
|
|
211
|
+
className="group flex flex-col rounded-md outline-offset-2 hover:opacity-90 focus-visible:outline-solid focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-focus-ring"
|
|
212
|
+
href={href}
|
|
213
|
+
>
|
|
214
|
+
{media}
|
|
215
|
+
|
|
216
|
+
{caption}
|
|
217
|
+
</a>
|
|
218
|
+
) : (
|
|
219
|
+
<>
|
|
220
|
+
{media}
|
|
221
|
+
|
|
222
|
+
{caption}
|
|
223
|
+
</>
|
|
224
|
+
)}
|
|
225
|
+
</figure>
|
|
226
|
+
)
|
|
227
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { StatusTransitions } from "@charcuterie/logic"
|
|
2
|
+
|
|
3
|
+
export type MediaStatus = "error" | "loaded" | "loading"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A component-owned machine, and the smallest honest example of what
|
|
7
|
+
* `createStatus` is for.
|
|
8
|
+
*
|
|
9
|
+
* `@charcuterie/logic` ships only the two machines that are
|
|
10
|
+
* genuinely fleet-wide — connection and async request — precisely so
|
|
11
|
+
* that a machine like this one lives next to the thing it describes.
|
|
12
|
+
* Three states, two of them terminal-ish, and the edges matter:
|
|
13
|
+
*
|
|
14
|
+
* - `loaded → loading` and `error → loading` exist for a changed
|
|
15
|
+
* `src`. Without them a poster grid that repaints with new URLs
|
|
16
|
+
* keeps showing the old tile's error.
|
|
17
|
+
* - `loaded → error` does **not** exist. A browser does not fire
|
|
18
|
+
* `error` on an image it has already decoded, and an app that
|
|
19
|
+
* thinks it saw that is misreading its own event wiring — so the
|
|
20
|
+
* machine throws rather than flickering a fallback over a
|
|
21
|
+
* perfectly good poster.
|
|
22
|
+
*/
|
|
23
|
+
export const mediaTransitions: StatusTransitions<MediaStatus> =
|
|
24
|
+
{
|
|
25
|
+
error: ["loading"],
|
|
26
|
+
loaded: ["loading"],
|
|
27
|
+
loading: ["error", "loaded"],
|
|
28
|
+
}
|