wings-plugin-ui 0.3.0 → 0.5.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 +7 -2
- package/emoji.js +1632 -0
- package/markdown.js +13 -3
- package/package.json +2 -1
- package/plugin-ui.css +216 -0
- package/ui.d.ts +32 -1
- package/ui.js +162 -5
package/markdown.js
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
// it, and the only URLs it lets through are https links and https or small data:image images.
|
|
3
3
|
//
|
|
4
4
|
// Blocks: headings, paragraphs, lists (flat, with an indent depth, and task items), fenced code, quotes, rules and
|
|
5
|
-
// GFM tables. Inline: code, bold, italics, strikethrough, links, images
|
|
5
|
+
// GFM tables. Inline: code, bold, italics, strikethrough, links, images, bare https URLs and emoji shortcodes
|
|
6
|
+
// (`:white_check_mark:`), which stay literal inside code and when the name is unknown.
|
|
7
|
+
|
|
8
|
+
import { EMOJI } from "./emoji.js";
|
|
6
9
|
|
|
7
10
|
/** A data: image longer than this, in characters, is shown as its alt text. */
|
|
8
11
|
export const MAX_DATA_IMAGE = 8000;
|
|
@@ -142,7 +145,11 @@ export function parseBlocks(src) {
|
|
|
142
145
|
// Every scan is bounded and stops at a newline, so a long run of brackets or markers costs a bounded step per start
|
|
143
146
|
// rather than a pass over the rest of the text.
|
|
144
147
|
const inlinePattern =
|
|
145
|
-
/\[!\[([^\[\]\n]{0,200})\]\(([^)\s]{0,2000})\)\]\(([^)\s]{1,2000})\)|!\[([^\[\]\n]{0,200})\]\(([^)\s]{0,2000})\)|\[([^\[\]\n]{1,200})\]\(([^)\s]{1,2000})\)|`([^`\n]{1,2000})`|\*\*([^*\n]{1,2000})\*\*|~~([^~\n]{1,2000})~~|\*([^*\s][^*\n]{0,2000})\*|(https:\/\/[^\s<>"'`)\]]{1,2000})
|
|
148
|
+
/\[!\[([^\[\]\n]{0,200})\]\(([^)\s]{0,2000})\)\]\(([^)\s]{1,2000})\)|!\[([^\[\]\n]{0,200})\]\(([^)\s]{0,2000})\)|\[([^\[\]\n]{1,200})\]\(([^)\s]{1,2000})\)|`([^`\n]{1,2000})`|\*\*([^*\n]{1,2000})\*\*|~~([^~\n]{1,2000})~~|\*([^*\s][^*\n]{0,2000})\*|(https:\/\/[^\s<>"'`)\]]{1,2000})|:([a-z0-9_+-]{1,40}):/gi;
|
|
149
|
+
|
|
150
|
+
/** `text` with its known `:shortcodes:` as emoji, for spans that are plain text, like a bold or a link's text. */
|
|
151
|
+
export const emojify = (text) =>
|
|
152
|
+
text.includes(":") ? text.replace(/:([a-z0-9_+-]{1,40}):/gi, (all, name) => (Object.hasOwn(EMOJI, name.toLowerCase()) ? EMOJI[name.toLowerCase()] : all)) : text;
|
|
146
153
|
|
|
147
154
|
const sentenceEnd = ".,;:!?";
|
|
148
155
|
|
|
@@ -172,7 +179,10 @@ export function parseInline(src) {
|
|
|
172
179
|
else if (m[9] !== undefined) out.push({ kind: "strong", text: m[9] });
|
|
173
180
|
else if (m[10] !== undefined) out.push({ kind: "del", text: m[10] });
|
|
174
181
|
else if (m[11] !== undefined) out.push({ kind: "em", text: m[11] });
|
|
175
|
-
else {
|
|
182
|
+
else if (m[13] !== undefined) {
|
|
183
|
+
const emoji = Object.hasOwn(EMOJI, m[13].toLowerCase()) ? EMOJI[m[13].toLowerCase()] : null;
|
|
184
|
+
out.push({ kind: "text", text: emoji ?? m[0] });
|
|
185
|
+
} else {
|
|
176
186
|
// A bare URL, without the punctuation that ends a sentence.
|
|
177
187
|
let end = m[12].length;
|
|
178
188
|
while (end > 0 && sentenceEnd.includes(m[12][end - 1])) end--;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wings-plugin-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "React components and the stylesheet for Wings plugin pages, on Base UI like the Wings app's shadcn/ui components.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"files": [
|
|
16
16
|
"ui.js",
|
|
17
17
|
"markdown.js",
|
|
18
|
+
"emoji.js",
|
|
18
19
|
"ui.d.ts",
|
|
19
20
|
"plugin-ui.css",
|
|
20
21
|
"README.md",
|
package/plugin-ui.css
CHANGED
|
@@ -744,6 +744,32 @@ textarea::placeholder {
|
|
|
744
744
|
}
|
|
745
745
|
|
|
746
746
|
/* Status: a dot, a spinner and a badge. */
|
|
747
|
+
.w-avatar {
|
|
748
|
+
display: inline-flex;
|
|
749
|
+
flex: none;
|
|
750
|
+
align-items: center;
|
|
751
|
+
justify-content: center;
|
|
752
|
+
width: 24px;
|
|
753
|
+
height: 24px;
|
|
754
|
+
border-radius: 50%;
|
|
755
|
+
background: hsl(var(--w-hue) 45% 32%);
|
|
756
|
+
color: hsl(var(--w-hue) 70% 90%);
|
|
757
|
+
font-size: 10px;
|
|
758
|
+
font-weight: 600;
|
|
759
|
+
line-height: 1;
|
|
760
|
+
letter-spacing: 0.02em;
|
|
761
|
+
user-select: none;
|
|
762
|
+
}
|
|
763
|
+
.w-avatar[data-size="small"] {
|
|
764
|
+
width: 18px;
|
|
765
|
+
height: 18px;
|
|
766
|
+
font-size: 8px;
|
|
767
|
+
}
|
|
768
|
+
.w-avatar[data-size="large"] {
|
|
769
|
+
width: 32px;
|
|
770
|
+
height: 32px;
|
|
771
|
+
font-size: 12px;
|
|
772
|
+
}
|
|
747
773
|
.w-dot {
|
|
748
774
|
flex: none;
|
|
749
775
|
width: 6px;
|
|
@@ -1661,4 +1687,194 @@ span.w-md {
|
|
|
1661
1687
|
.w-md-table [data-align="right"] {
|
|
1662
1688
|
text-align: right;
|
|
1663
1689
|
}
|
|
1690
|
+
|
|
1691
|
+
/* Activity: a summary row, skeletons and a timeline, shared by pages like a pull request and a work item. */
|
|
1692
|
+
.w-summary {
|
|
1693
|
+
display: flex;
|
|
1694
|
+
flex-wrap: wrap;
|
|
1695
|
+
align-items: center;
|
|
1696
|
+
gap: 6px;
|
|
1697
|
+
}
|
|
1698
|
+
.w-summary-chip {
|
|
1699
|
+
display: inline-flex;
|
|
1700
|
+
align-items: center;
|
|
1701
|
+
gap: 6px;
|
|
1702
|
+
max-width: 100%;
|
|
1703
|
+
height: 26px;
|
|
1704
|
+
padding: 0 10px;
|
|
1705
|
+
border: 1px solid var(--wings-hairline-strong);
|
|
1706
|
+
border-radius: 999px;
|
|
1707
|
+
background: var(--wings-surface);
|
|
1708
|
+
color: var(--wings-fg);
|
|
1709
|
+
font-size: 12px;
|
|
1710
|
+
font-weight: 500;
|
|
1711
|
+
white-space: nowrap;
|
|
1712
|
+
}
|
|
1713
|
+
button.w-summary-chip {
|
|
1714
|
+
cursor: pointer;
|
|
1715
|
+
transition: background-color 150ms;
|
|
1716
|
+
}
|
|
1717
|
+
button.w-summary-chip:hover {
|
|
1718
|
+
background: var(--wings-hover);
|
|
1719
|
+
}
|
|
1720
|
+
button.w-summary-chip:focus-visible {
|
|
1721
|
+
outline: 2px solid var(--wings-ring);
|
|
1722
|
+
outline-offset: 2px;
|
|
1723
|
+
}
|
|
1724
|
+
.w-summary-chip[data-tone="info"] {
|
|
1725
|
+
--w-tone: var(--wings-info);
|
|
1726
|
+
}
|
|
1727
|
+
.w-summary-chip[data-tone="success"] {
|
|
1728
|
+
--w-tone: var(--wings-success);
|
|
1729
|
+
}
|
|
1730
|
+
.w-summary-chip[data-tone="warn"] {
|
|
1731
|
+
--w-tone: var(--wings-warning);
|
|
1732
|
+
}
|
|
1733
|
+
.w-summary-chip[data-tone="danger"] {
|
|
1734
|
+
--w-tone: var(--wings-danger);
|
|
1735
|
+
}
|
|
1736
|
+
.w-summary-chip[data-tone] {
|
|
1737
|
+
border-color: color-mix(in srgb, var(--w-tone) 40%, transparent);
|
|
1738
|
+
color: var(--w-tone);
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
.w-visually-hidden {
|
|
1742
|
+
position: absolute;
|
|
1743
|
+
width: 1px;
|
|
1744
|
+
height: 1px;
|
|
1745
|
+
overflow: hidden;
|
|
1746
|
+
clip-path: inset(50%);
|
|
1747
|
+
white-space: nowrap;
|
|
1748
|
+
}
|
|
1749
|
+
.w-skeletons {
|
|
1750
|
+
display: flex;
|
|
1751
|
+
flex-direction: column;
|
|
1752
|
+
gap: 12px;
|
|
1753
|
+
}
|
|
1754
|
+
.w-skeleton {
|
|
1755
|
+
display: block;
|
|
1756
|
+
width: var(--w-width, 100%);
|
|
1757
|
+
height: var(--w-height, 14px);
|
|
1758
|
+
border-radius: var(--wings-radius);
|
|
1759
|
+
background: linear-gradient(90deg, var(--wings-surface), var(--wings-hover), var(--wings-surface)) 0 0 / 200% 100%;
|
|
1760
|
+
animation: w-shimmer 1.4s ease-in-out infinite;
|
|
1761
|
+
}
|
|
1762
|
+
@keyframes w-shimmer {
|
|
1763
|
+
to {
|
|
1764
|
+
background-position: -200% 0;
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1768
|
+
.w-skeleton {
|
|
1769
|
+
animation: none;
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
.w-timeline {
|
|
1774
|
+
display: flex;
|
|
1775
|
+
flex-direction: column;
|
|
1776
|
+
gap: 10px;
|
|
1777
|
+
min-width: 0;
|
|
1778
|
+
margin: 0;
|
|
1779
|
+
padding: 0;
|
|
1780
|
+
list-style: none;
|
|
1781
|
+
}
|
|
1782
|
+
.w-timeline-item {
|
|
1783
|
+
min-width: 0;
|
|
1784
|
+
content-visibility: auto;
|
|
1785
|
+
contain-intrinsic-size: auto 96px;
|
|
1786
|
+
}
|
|
1787
|
+
.w-timeline-card {
|
|
1788
|
+
display: flex;
|
|
1789
|
+
flex-direction: column;
|
|
1790
|
+
gap: 10px;
|
|
1791
|
+
min-width: 0;
|
|
1792
|
+
padding: 12px 14px;
|
|
1793
|
+
border: 1px solid var(--wings-hairline);
|
|
1794
|
+
border-radius: var(--wings-radius);
|
|
1795
|
+
background: var(--wings-surface);
|
|
1796
|
+
}
|
|
1797
|
+
.w-timeline-item[data-quiet] > .w-timeline-card {
|
|
1798
|
+
background: transparent;
|
|
1799
|
+
font-size: 12px;
|
|
1800
|
+
}
|
|
1801
|
+
.w-timeline-head {
|
|
1802
|
+
display: flex;
|
|
1803
|
+
flex-wrap: wrap;
|
|
1804
|
+
align-items: center;
|
|
1805
|
+
gap: 4px 8px;
|
|
1806
|
+
min-width: 0;
|
|
1807
|
+
}
|
|
1808
|
+
.w-timeline-author {
|
|
1809
|
+
font-weight: 600;
|
|
1810
|
+
overflow-wrap: anywhere;
|
|
1811
|
+
}
|
|
1812
|
+
.w-timeline-tag {
|
|
1813
|
+
padding: 0 6px;
|
|
1814
|
+
border: 1px solid var(--wings-hairline-strong);
|
|
1815
|
+
border-radius: 999px;
|
|
1816
|
+
color: var(--wings-muted);
|
|
1817
|
+
font-size: 11px;
|
|
1818
|
+
line-height: 16px;
|
|
1819
|
+
}
|
|
1820
|
+
.w-timeline-time {
|
|
1821
|
+
color: var(--wings-muted);
|
|
1822
|
+
font-size: 12px;
|
|
1823
|
+
}
|
|
1824
|
+
.w-timeline-actions {
|
|
1825
|
+
display: inline-flex;
|
|
1826
|
+
align-items: center;
|
|
1827
|
+
gap: 6px;
|
|
1828
|
+
margin-left: auto;
|
|
1829
|
+
}
|
|
1830
|
+
.w-timeline-note {
|
|
1831
|
+
display: flex;
|
|
1832
|
+
align-items: center;
|
|
1833
|
+
gap: 8px;
|
|
1834
|
+
padding: 0 14px;
|
|
1835
|
+
color: var(--wings-muted);
|
|
1836
|
+
font-size: 12px;
|
|
1837
|
+
}
|
|
1838
|
+
.w-timeline-note-text {
|
|
1839
|
+
min-width: 0;
|
|
1840
|
+
overflow-wrap: anywhere;
|
|
1841
|
+
}
|
|
1842
|
+
.w-timeline-note-text strong {
|
|
1843
|
+
color: var(--wings-fg);
|
|
1844
|
+
font-weight: 600;
|
|
1845
|
+
}
|
|
1846
|
+
.w-clamp {
|
|
1847
|
+
display: flex;
|
|
1848
|
+
flex-direction: column;
|
|
1849
|
+
align-items: flex-start;
|
|
1850
|
+
gap: 4px;
|
|
1851
|
+
min-width: 0;
|
|
1852
|
+
}
|
|
1853
|
+
.w-clamp-box {
|
|
1854
|
+
width: 100%;
|
|
1855
|
+
min-width: 0;
|
|
1856
|
+
}
|
|
1857
|
+
.w-clamp-box[data-clamped] {
|
|
1858
|
+
max-height: 11em;
|
|
1859
|
+
overflow: hidden;
|
|
1860
|
+
}
|
|
1861
|
+
.w-clamp-box[data-cut] {
|
|
1862
|
+
mask-image: linear-gradient(to bottom, #000 70%, transparent);
|
|
1863
|
+
}
|
|
1864
|
+
.w-clamp-toggle {
|
|
1865
|
+
padding: 2px 6px;
|
|
1866
|
+
border: 0;
|
|
1867
|
+
border-radius: 6px;
|
|
1868
|
+
background: transparent;
|
|
1869
|
+
color: var(--wings-info);
|
|
1870
|
+
font-size: 12px;
|
|
1871
|
+
cursor: pointer;
|
|
1872
|
+
}
|
|
1873
|
+
.w-clamp-toggle:hover {
|
|
1874
|
+
background: var(--wings-hover);
|
|
1875
|
+
}
|
|
1876
|
+
.w-clamp-toggle:focus-visible {
|
|
1877
|
+
outline: 2px solid var(--wings-ring);
|
|
1878
|
+
outline-offset: 2px;
|
|
1879
|
+
}
|
|
1664
1880
|
}
|
package/ui.d.ts
CHANGED
|
@@ -174,6 +174,11 @@ export function Dot(
|
|
|
174
174
|
label?: string;
|
|
175
175
|
},
|
|
176
176
|
): ReactElement;
|
|
177
|
+
/**
|
|
178
|
+
* A round avatar with the initials of `name` (up to two letters) on a colour that depends only on the name, so a person
|
|
179
|
+
* looks the same everywhere. Decoration next to the name unless given a `label`, which names it for assistive tech.
|
|
180
|
+
*/
|
|
181
|
+
export function Avatar(props: Props<"span"> & { name: string; size?: "small" | "large"; label?: string }): ReactElement;
|
|
177
182
|
/** A spinner, 16px like the app's, in the colour of the text around it. With a `label` it's announced; otherwise it's decoration. */
|
|
178
183
|
export function Spinner(props: Props<"span"> & { size?: "large"; label?: string }): ReactElement;
|
|
179
184
|
/** shadcn/ui's Badge variants. */
|
|
@@ -339,7 +344,8 @@ export function Disclosure(
|
|
|
339
344
|
|
|
340
345
|
/**
|
|
341
346
|
* Markdown as React elements: headings, paragraphs, lists (with read-only task checkboxes), fenced code, quotes,
|
|
342
|
-
* rules, GFM tables, bold, italics, strikethrough, inline code, links, bare https URLs
|
|
347
|
+
* rules, GFM tables, bold, italics, strikethrough, inline code, links, bare https URLs, images and emoji shortcodes like
|
|
348
|
+
* `:white_check_mark:` (unknown names and anything in code stay as typed). No HTML is ever
|
|
343
349
|
* parsed. Links open through `wings.openUrl` and only when they are https, so the manifest needs
|
|
344
350
|
* `permissions.openUrl: ["https://"]` for them to work. Images load only from https or `data:image/` (png, jpeg, gif,
|
|
345
351
|
* webp) URLs of up to 8,000 characters; anything else shows its alt text. A remote https image shows as a "Load image from <host>" button and loads only when clicked, since text an agent wrote could otherwise leak data in the URL; a data: image shows at once. `inline` renders only the inline parts, in
|
|
@@ -348,3 +354,28 @@ export function Disclosure(
|
|
|
348
354
|
export function Markdown(props: Omit<Props<"div">, "children"> & { source: string; inline?: boolean }): ReactElement;
|
|
349
355
|
/** The longest `data:image/` URL, in characters, that `Markdown` shows. */
|
|
350
356
|
export const MAX_DATA_IMAGE: number;
|
|
357
|
+
|
|
358
|
+
// ---------- activity: times, a summary row, skeletons and a timeline ----------
|
|
359
|
+
|
|
360
|
+
/** "just now", "5 min ago", "3 h ago", "2 d ago", then the date; "" when `iso` isn't a date. `now` is for tests. */
|
|
361
|
+
export function ago(iso: string, now?: number): string;
|
|
362
|
+
/** A moment as a `time` element: how long ago (after `prefix`), the exact date and time as its title. Renders nothing when `iso` isn't a date. */
|
|
363
|
+
export function Time(props: Omit<Props<"time">, "dateTime" | "children"> & { iso: string; prefix?: ReactNode; now?: number }): ReactElement | null;
|
|
364
|
+
/** A row of `SummaryChip`s under a page's header. */
|
|
365
|
+
export function Summary(props: Props<"div">): ReactElement;
|
|
366
|
+
/** One fact in a `Summary`: a button when it has `onClick`, text otherwise. `tone` colours it. */
|
|
367
|
+
export function SummaryChip(props: Omit<Props<"span">, "onClick"> & { tone?: "info" | "success" | "warn" | "danger"; onClick?: Props<"button">["onClick"]; type?: "button" | "submit" }): ReactElement;
|
|
368
|
+
/** A block that stands in for loading content, `width` and `height` in px or any CSS length. Decoration: wrap a group in `Skeletons`. */
|
|
369
|
+
export function Skeleton(props: Props<"span"> & { width?: number | string; height?: number | string }): ReactElement;
|
|
370
|
+
/** A group of `Skeleton`s announced once as `label`, with `aria-busy`. */
|
|
371
|
+
export function Skeletons(props: Omit<Props<"div">, "role" | "aria-label"> & { label: string }): ReactElement;
|
|
372
|
+
/** An ordered list of `TimelineCard`s and `TimelineNote`s, named `label`. */
|
|
373
|
+
export function Timeline(props: Props<"ol"> & { label: string }): ReactElement;
|
|
374
|
+
/**
|
|
375
|
+
* An entry of a `Timeline`: the `Avatar`, name and time of `author` and `at` (ISO), an optional `tag` and `actions`
|
|
376
|
+
* at the right of that line, and `children` as the body. `quiet` for what a bot or the system wrote; `clamp` cuts a long
|
|
377
|
+
* body until "Show more".
|
|
378
|
+
*/
|
|
379
|
+
export function TimelineCard(props: Omit<Props<"li">, "children"> & { author: string; at?: string; tag?: ReactNode; actions?: ReactNode; quiet?: boolean; clamp?: boolean; children?: ReactNode }): ReactElement;
|
|
380
|
+
/** A quiet one-line entry of a `Timeline`: a small avatar, the author in bold, `children`, then the time. */
|
|
381
|
+
export function TimelineNote(props: Omit<Props<"li">, "children"> & { author: string; at?: string; children?: ReactNode }): ReactElement;
|
package/ui.js
CHANGED
|
@@ -17,7 +17,7 @@ import { ToggleGroup as BaseToggleGroup } from "@base-ui/react/toggle-group";
|
|
|
17
17
|
import { createContext, createElement as h, useContext, useEffect, useId, memo, useLayoutEffect, useRef, useState } from "react";
|
|
18
18
|
import { createRoot } from "react-dom/client";
|
|
19
19
|
|
|
20
|
-
import { MAX_DATA_IMAGE, parseBlocks, parseInline } from "./markdown.js";
|
|
20
|
+
import { MAX_DATA_IMAGE, emojify, parseBlocks, parseInline } from "./markdown.js";
|
|
21
21
|
|
|
22
22
|
export { MAX_DATA_IMAGE };
|
|
23
23
|
|
|
@@ -365,6 +365,38 @@ export function Dot({ tone, color, label, className, style, ...rest }) {
|
|
|
365
365
|
});
|
|
366
366
|
}
|
|
367
367
|
|
|
368
|
+
/** Up to two initials of a name or login: "Ada Lovelace" is AL, "ada-bot[bot]" is AB, "ada" is A. */
|
|
369
|
+
const initialsOf = (name) => {
|
|
370
|
+
const words = name.replace(/\[bot\]$/i, "").split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
|
371
|
+
return (words.length > 1 ? words[0][0] + words[1][0] : (words[0] ?? "?").slice(0, 1)).toUpperCase();
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
/** A hue from 0 to 359 that depends only on the name, so a person has the same colour everywhere. */
|
|
375
|
+
const hueOf = (name) => {
|
|
376
|
+
let hash = 0;
|
|
377
|
+
for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
|
|
378
|
+
return hash % 360;
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* A round avatar with the initials of a `name` on a colour taken from it. Decoration next to the name by default;
|
|
383
|
+
* with a `label` it's announced.
|
|
384
|
+
*/
|
|
385
|
+
export function Avatar({ name, size, label, className, style, ...rest }) {
|
|
386
|
+
return h(
|
|
387
|
+
"span",
|
|
388
|
+
{
|
|
389
|
+
...rest,
|
|
390
|
+
className: cx("w-avatar", className),
|
|
391
|
+
// The hue comes from the name, as data: plugin-ui.css paints the avatar with it.
|
|
392
|
+
style: { ...style, "--w-hue": hueOf(name) },
|
|
393
|
+
"data-size": size,
|
|
394
|
+
...(label ? { role: "img", "aria-label": label } : { "aria-hidden": true }),
|
|
395
|
+
},
|
|
396
|
+
initialsOf(name),
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
368
400
|
/** A spinner. With a `label` it's announced. */
|
|
369
401
|
export function Spinner({ size, label, className, ...rest }) {
|
|
370
402
|
return h("span", { ...rest, className: cx("w-spinner", className), "data-size": size, ...(label ? { role: "img", "aria-label": label } : { "aria-hidden": true }) });
|
|
@@ -795,15 +827,15 @@ function MdInline({ text }) {
|
|
|
795
827
|
case "code":
|
|
796
828
|
return h("code", { key: i, className: "w-md-code" }, part.text);
|
|
797
829
|
case "strong":
|
|
798
|
-
return h("strong", { key: i }, part.text);
|
|
830
|
+
return h("strong", { key: i }, emojify(part.text));
|
|
799
831
|
case "em":
|
|
800
|
-
return h("em", { key: i }, part.text);
|
|
832
|
+
return h("em", { key: i }, emojify(part.text));
|
|
801
833
|
case "del":
|
|
802
|
-
return h("del", { key: i }, part.text);
|
|
834
|
+
return h("del", { key: i }, emojify(part.text));
|
|
803
835
|
case "image":
|
|
804
836
|
return h(MdImage, { key: i, src: part.src, alt: part.alt });
|
|
805
837
|
case "link":
|
|
806
|
-
return h("a", { key: i, className: "w-md-link", href: part.href, onClick: (e) => openLink(e, part.href) }, part.text);
|
|
838
|
+
return h("a", { key: i, className: "w-md-link", href: part.href, onClick: (e) => openLink(e, part.href) }, emojify(part.text));
|
|
807
839
|
default:
|
|
808
840
|
return part.text;
|
|
809
841
|
}
|
|
@@ -871,3 +903,128 @@ export const Markdown = memo(function Markdown({ source, inline, className, ...r
|
|
|
871
903
|
}),
|
|
872
904
|
);
|
|
873
905
|
});
|
|
906
|
+
|
|
907
|
+
// ---------- activity: times, a summary row, skeletons and a timeline ----------
|
|
908
|
+
|
|
909
|
+
/** "just now", "5 min ago", "3 h ago", "2 d ago", then the date. "" for something that isn't a date. */
|
|
910
|
+
export function ago(iso, now = Date.now()) {
|
|
911
|
+
const t = Date.parse(iso);
|
|
912
|
+
if (Number.isNaN(t)) return "";
|
|
913
|
+
const minutes = Math.max(0, Math.round((now - t) / 60_000));
|
|
914
|
+
if (minutes < 1) return "just now";
|
|
915
|
+
if (minutes < 60) return `${minutes} min ago`;
|
|
916
|
+
if (minutes < 1440) return `${Math.round(minutes / 60)} h ago`;
|
|
917
|
+
if (minutes < 43_200) return `${Math.round(minutes / 1440)} d ago`;
|
|
918
|
+
return new Date(t).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/** A moment as a `time` element: how long ago, with the exact date and time on hover. Nothing when `iso` isn't a date. */
|
|
922
|
+
export function Time({ iso, prefix, now, className, ...rest }) {
|
|
923
|
+
const text = ago(iso, now);
|
|
924
|
+
if (!text) return null;
|
|
925
|
+
return h("time", { title: new Date(iso).toLocaleString(), ...rest, className, dateTime: iso }, prefix, text);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/** A row of `SummaryChip`s under a page's header: the facts about the thing at a glance. */
|
|
929
|
+
export function Summary({ className, ...rest }) {
|
|
930
|
+
return h("div", { ...rest, className: cx("w-summary", className) });
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/** One fact in a `Summary`. With `onClick` it's a button (it takes you to where the fact comes from); without, plain text. */
|
|
934
|
+
export function SummaryChip({ tone, className, onClick, type = "button", ...rest }) {
|
|
935
|
+
const props = { ...rest, className: cx("w-summary-chip", className), "data-tone": tone };
|
|
936
|
+
return onClick ? h("button", { ...props, type, onClick }) : h("span", props);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/** A grey block that stands in for content that is still loading, sized by `width` and `height` (CSS lengths or px). It shimmers unless the user asked for less motion. */
|
|
940
|
+
export function Skeleton({ width, height, className, style, ...rest }) {
|
|
941
|
+
const size = (v) => (typeof v === "number" ? `${v}px` : v);
|
|
942
|
+
return h("span", {
|
|
943
|
+
...rest,
|
|
944
|
+
className: cx("w-skeleton", className),
|
|
945
|
+
style: { ...style, ...(width === undefined ? null : { "--w-width": size(width) }), ...(height === undefined ? null : { "--w-height": size(height) }) },
|
|
946
|
+
"aria-hidden": true,
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/** Skeletons that stand in for a section, announced once as `label` and `aria-busy`. */
|
|
951
|
+
export function Skeletons({ label, children, className, ...rest }) {
|
|
952
|
+
// The label is text inside the region, which a live region announces; an aria-label alone isn't.
|
|
953
|
+
return h("div", { ...rest, className: cx("w-skeletons", className), role: "status", "aria-busy": true }, h("span", { className: "w-visually-hidden" }, label), children);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/** A conversation or an activity feed: an ordered list of `TimelineCard`s and `TimelineNote`s. */
|
|
957
|
+
export function Timeline({ label, className, ...rest }) {
|
|
958
|
+
return h("ol", { ...rest, className: cx("w-timeline", className), "aria-label": label });
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
const FOCUSABLE = "a[href], button, input, select, textarea, summary, [tabindex]";
|
|
962
|
+
|
|
963
|
+
/** Cuts `children` after a few lines until the reader asks for the rest; focusable things below the cut are inert meanwhile. The button only shows when something was cut. */
|
|
964
|
+
function Clamped({ children }) {
|
|
965
|
+
const box = useRef(null);
|
|
966
|
+
const [more, setMore] = useState(false);
|
|
967
|
+
const [cut, setCut] = useState(false);
|
|
968
|
+
useLayoutEffect(() => {
|
|
969
|
+
const el = box.current;
|
|
970
|
+
if (!el) return;
|
|
971
|
+
if (more) {
|
|
972
|
+
for (const f of el.querySelectorAll(FOCUSABLE)) f.inert = false;
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
const measure = () => {
|
|
976
|
+
setCut(el.scrollHeight > el.clientHeight + 1);
|
|
977
|
+
// What the cut hides can't be reached by keyboard or screen reader until "Show more".
|
|
978
|
+
const bottom = el.getBoundingClientRect().bottom;
|
|
979
|
+
for (const f of el.querySelectorAll(FOCUSABLE)) f.inert = f.getBoundingClientRect().top >= bottom - 1;
|
|
980
|
+
};
|
|
981
|
+
measure();
|
|
982
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
983
|
+
const watch = new ResizeObserver(measure);
|
|
984
|
+
watch.observe(el);
|
|
985
|
+
return () => watch.disconnect();
|
|
986
|
+
}, [more]);
|
|
987
|
+
return h(
|
|
988
|
+
"div",
|
|
989
|
+
{ className: "w-clamp" },
|
|
990
|
+
h("div", { ref: box, className: "w-clamp-box", "data-clamped": more ? undefined : "", "data-cut": cut && !more ? "" : undefined }, children),
|
|
991
|
+
cut || more ? h("button", { type: "button", className: "w-clamp-toggle", "aria-expanded": more, onClick: () => setMore(!more) }, more ? "Show less" : "Show more") : null,
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* One entry of a `Timeline`, a card: the author's `Avatar`, name and when, a `tag` (like "Author") and `actions` on the
|
|
997
|
+
* right, and `children` as the body. `quiet` draws it without a fill, for what a bot or the system wrote. `clamp` cuts
|
|
998
|
+
* a long body until "Show more" is pressed.
|
|
999
|
+
*/
|
|
1000
|
+
export function TimelineCard({ author, at, tag, actions, quiet, clamp, children, className, ...rest }) {
|
|
1001
|
+
return h(
|
|
1002
|
+
"li",
|
|
1003
|
+
{ ...rest, className: cx("w-timeline-item", className), "data-quiet": quiet ? "" : undefined },
|
|
1004
|
+
h(
|
|
1005
|
+
"article",
|
|
1006
|
+
{ className: "w-timeline-card", "aria-label": `By ${author}` },
|
|
1007
|
+
h(
|
|
1008
|
+
"div",
|
|
1009
|
+
{ className: "w-timeline-head" },
|
|
1010
|
+
h(Avatar, { name: author }),
|
|
1011
|
+
h("strong", { className: "w-timeline-author" }, author),
|
|
1012
|
+
tag ? h("span", { className: "w-timeline-tag" }, tag) : null,
|
|
1013
|
+
h(Time, { iso: at, className: "w-timeline-time" }),
|
|
1014
|
+
actions ? h("span", { className: "w-timeline-actions" }, actions) : null,
|
|
1015
|
+
),
|
|
1016
|
+
clamp ? h(Clamped, null, children) : children,
|
|
1017
|
+
),
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/** A quiet one-line entry of a `Timeline`, like "Ada moved it to Active": a small avatar, then `children`, then when. */
|
|
1022
|
+
export function TimelineNote({ author, at, children, className, ...rest }) {
|
|
1023
|
+
return h(
|
|
1024
|
+
"li",
|
|
1025
|
+
{ ...rest, className: cx("w-timeline-item w-timeline-note", className) },
|
|
1026
|
+
h(Avatar, { name: author, size: "small" }),
|
|
1027
|
+
h("span", { className: "w-timeline-note-text" }, h("strong", null, author), " ", children),
|
|
1028
|
+
h(Time, { iso: at, className: "w-timeline-time" }),
|
|
1029
|
+
);
|
|
1030
|
+
}
|