@heyramzi/ui 3.8.0 → 3.9.1
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/dist/charts/motion.js +2 -7
- package/dist/lib/safe-href.js +13 -0
- package/dist/record/fields.js +15 -1
- package/package.json +1 -1
- package/src/charts/dashboard/data-table.svelte +2 -1
- package/src/charts/motion.ts +2 -7
- package/src/components/data-table/row-context-menu.svelte +4 -2
- package/src/components/open-in/open-in.svelte +2 -1
- package/src/components/table-view/table-view.svelte +4 -1
- package/src/components/table-view/table-view.svelte.d.ts +2 -0
- package/src/inbox/thread-inbox.svelte +3 -2
- package/src/lib/safe-href.test.ts +34 -0
- package/src/lib/safe-href.ts +14 -0
- package/src/record/fields.ts +13 -1
- package/src/record/info-list.svelte +3 -2
- package/src/record/info-list.test.ts +26 -0
- package/src/record/next-up.svelte +3 -2
- package/src/record/project-list.svelte +4 -2
- package/src/record/record-header.svelte +2 -1
- package/src/record/record-timeline.svelte +4 -3
- package/src/record/record.test.ts +19 -0
- package/src/record/where-we-are.svelte +3 -2
- package/src/shadcn/alert-dialog/alert-dialog-cancel.svelte +3 -0
- package/src/shadcn/command/command-dialog.svelte +4 -0
- package/src/shadcn/command/command-dialog.svelte.d.ts +2 -0
- package/src/shadcn/dialog/dialog-content.svelte +4 -1
- package/src/shadcn/dialog/dialog-content.svelte.d.ts +2 -0
- package/src/shadcn/dialog/dialog-footer.svelte +4 -1
- package/src/shadcn/dialog/dialog-footer.svelte.d.ts +2 -0
- package/src/shadcn/input/secret-input.svelte +17 -3
- package/src/shadcn/input/secret-input.svelte.d.ts +6 -0
- package/src/shadcn/message-scroller/message-scroller-button.svelte +9 -3
- package/src/shadcn/message-scroller/message-scroller-button.svelte.d.ts +5 -0
- package/src/shadcn/pagination/pagination-next.svelte +6 -2
- package/src/shadcn/pagination/pagination-next.svelte.d.ts +5 -1
- package/src/shadcn/pagination/pagination-previous.svelte +6 -2
- package/src/shadcn/pagination/pagination-previous.svelte.d.ts +5 -1
- package/src/shadcn/sheet/close-label.test.svelte +20 -0
- package/src/shadcn/sheet/close-label.test.svelte.d.ts +6 -0
- package/src/shadcn/sheet/close-label.test.ts +44 -0
- package/src/shadcn/sheet/sheet-content.svelte +4 -1
- package/src/shadcn/sheet/sheet-content.svelte.d.ts +2 -0
- package/src/shadcn/sidebar/sidebar-rail.svelte +7 -3
- package/src/shadcn/sidebar/sidebar-rail.svelte.d.ts +5 -1
- package/src/shadcn/sidebar/sidebar-trigger.svelte +4 -1
- package/src/shadcn/sidebar/sidebar-trigger.svelte.d.ts +2 -0
- package/src/thread/index.ts +1 -0
- package/src/thread/thread-events.svelte +34 -3
- package/src/thread/types.ts +10 -0
- package/src/triage/triage-task.svelte +2 -1
- package/src/work/inbox/inbox-card.svelte +3 -2
- package/src/work/inbox/inbox-trace.svelte +6 -4
- package/src/work/task-gantt.svelte +4 -1
- package/src/work/task-gantt.svelte.d.ts +2 -0
package/dist/charts/motion.js
CHANGED
|
@@ -10,10 +10,5 @@ export const DOMAIN_MOTION = {
|
|
|
10
10
|
/** The tooltip sits beside the hovered point at the top of the plot, so it never chases the cursor. */
|
|
11
11
|
export const TOOLTIP_TOP = 8;
|
|
12
12
|
export const TOOLTIP_GAP = 16;
|
|
13
|
-
/** A chart duration token in ms, read off a mounted element
|
|
14
|
-
export
|
|
15
|
-
const raw = getComputedStyle(el).getPropertyValue(name).trim();
|
|
16
|
-
const n = Number.parseFloat(raw);
|
|
17
|
-
if (Number.isNaN(n)) return fallback;
|
|
18
|
-
return raw.endsWith("ms") ? n : n * 1e3;
|
|
19
|
-
}
|
|
13
|
+
/** A chart duration token in ms, read off a mounted element. Lives in `lib/motion` now. */
|
|
14
|
+
export { tokenMs } from "../lib/motion.js";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// WHY: Svelte writes an href into the page as given, and following an <a href="javascript:...">
|
|
2
|
+
// (or setting location to one) runs that script in the page's origin, with the viewer's session.
|
|
3
|
+
const SCRIPT_SCHEME = /^(?:javascript|vbscript):/i;
|
|
4
|
+
/**
|
|
5
|
+
* The href as given, or undefined when following it would run script. App routes, https,
|
|
6
|
+
* mailto and app schemes like descript: pass, so a link a component builds from data stays a link.
|
|
7
|
+
*/
|
|
8
|
+
export function safeHref(href) {
|
|
9
|
+
if (!href) return undefined;
|
|
10
|
+
// WHY: the URL parser drops tabs, newlines and leading spaces or control bytes, so " java\tscript:" is javascript:.
|
|
11
|
+
const probe = [...href].filter((c) => c.charCodeAt(0) > 32 && c.charCodeAt(0) !== 127).join("");
|
|
12
|
+
return SCRIPT_SCHEME.test(probe) ? undefined : href;
|
|
13
|
+
}
|
package/dist/record/fields.js
CHANGED
|
@@ -4,6 +4,20 @@ import { formatDay, formatMoney } from "./format.js";
|
|
|
4
4
|
function hasOptions(field) {
|
|
5
5
|
return field.type === "choice" || field.type === "multi";
|
|
6
6
|
}
|
|
7
|
+
const WEB_PROTOCOLS = new Set([
|
|
8
|
+
"http:",
|
|
9
|
+
"https:",
|
|
10
|
+
"mailto:"
|
|
11
|
+
]);
|
|
12
|
+
/** A url field's link: http, https or mailto only. Anything else, javascript: above all, shows as text. */
|
|
13
|
+
function webLink(text) {
|
|
14
|
+
try {
|
|
15
|
+
const url = new URL(text);
|
|
16
|
+
return WEB_PROTOCOLS.has(url.protocol) ? url.href : undefined;
|
|
17
|
+
} catch {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
7
21
|
function isEmpty(value) {
|
|
8
22
|
return value == null || value === "" || Array.isArray(value) && value.length === 0;
|
|
9
23
|
}
|
|
@@ -107,7 +121,7 @@ export function fieldRow(field, value, ctx = {}) {
|
|
|
107
121
|
break;
|
|
108
122
|
case "url":
|
|
109
123
|
row.value = String(value).replace(/^https?:\/\/(www\.)?/, "").replace(/\/$/, "");
|
|
110
|
-
row.href = String(value);
|
|
124
|
+
row.href = webLink(String(value));
|
|
111
125
|
break;
|
|
112
126
|
case "email":
|
|
113
127
|
row.value = String(value);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heyramzi/ui",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.9.1",
|
|
4
4
|
"description": "Svelte 5 UI for apps: primitives, effects and animations, plus work (tasks and docs), charts, slides, an editor, onboarding and a canvas as subpaths.",
|
|
5
5
|
"homepage": "https://ui.heyramzi.com",
|
|
6
6
|
"license": "MIT",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
* optional pager. A row opens by `rowHref` or `onRowClick`, stretched over the row from the lead.
|
|
43
43
|
*/
|
|
44
44
|
import { cn } from "../../lib/utils.js";
|
|
45
|
+
import { safeHref } from "../../lib/safe-href.js";
|
|
45
46
|
import RatingChip from "./rating-chip.svelte";
|
|
46
47
|
import Hint from "./hint.svelte";
|
|
47
48
|
import {
|
|
@@ -216,7 +217,7 @@
|
|
|
216
217
|
<tr class={cn(dataTrClass, "group", opens && "relative cursor-pointer")}>
|
|
217
218
|
<td class={cn(dataTdClass, "py-3")}>
|
|
218
219
|
{#if rowHref}
|
|
219
|
-
<a href={rowHref(row)} class={stretch}>{@render lead(row)}</a>
|
|
220
|
+
<a href={safeHref(rowHref(row))} class={stretch}>{@render lead(row)}</a>
|
|
220
221
|
{:else if onRowClick}
|
|
221
222
|
<button type="button" class={stretch} onclick={() => onRowClick(row)}>
|
|
222
223
|
{@render lead(row)}
|
package/src/charts/motion.ts
CHANGED
|
@@ -10,10 +10,5 @@ export const DOMAIN_MOTION = { type: "tween", duration: 500, easing: cubicInOut
|
|
|
10
10
|
export const TOOLTIP_TOP = 8;
|
|
11
11
|
export const TOOLTIP_GAP = 16;
|
|
12
12
|
|
|
13
|
-
/** A chart duration token in ms, read off a mounted element
|
|
14
|
-
export
|
|
15
|
-
const raw = getComputedStyle(el).getPropertyValue(name).trim();
|
|
16
|
-
const n = Number.parseFloat(raw);
|
|
17
|
-
if (Number.isNaN(n)) return fallback;
|
|
18
|
-
return raw.endsWith("ms") ? n : n * 1000;
|
|
19
|
-
}
|
|
13
|
+
/** A chart duration token in ms, read off a mounted element. Lives in `lib/motion` now. */
|
|
14
|
+
export { tokenMs } from "../lib/motion.js";
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { HoldToConfirm } from "../hold-to-confirm/index.js";
|
|
7
7
|
import { ArrowUpRight, Copy, LinkSimple, Trash } from "phosphor-svelte";
|
|
8
8
|
import { copyText } from "../copy/clipboard.js";
|
|
9
|
+
import { safeHref } from "../../lib/safe-href.js";
|
|
9
10
|
import type { Snippet } from "svelte";
|
|
10
11
|
import { MENU_KEYS, openMenuFromKey } from "./menu-key.js";
|
|
11
12
|
|
|
@@ -42,7 +43,8 @@
|
|
|
42
43
|
// FUNCTIONS
|
|
43
44
|
// =========================
|
|
44
45
|
function handleOpen() {
|
|
45
|
-
|
|
46
|
+
const target = safeHref(href);
|
|
47
|
+
if (target) window.location.href = target;
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
// WHY the whole URL and not the path: what leaves this menu is pasted into a
|
|
@@ -68,7 +70,7 @@
|
|
|
68
70
|
{/snippet}
|
|
69
71
|
</ContextMenu.Trigger>
|
|
70
72
|
<ContextMenu.Content class="w-48">
|
|
71
|
-
{#if href}
|
|
73
|
+
{#if safeHref(href)}
|
|
72
74
|
<ContextMenu.Item onclick={handleOpen}>
|
|
73
75
|
<ArrowUpRight aria-hidden="true" size={14} />
|
|
74
76
|
Open
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
// =========================
|
|
26
26
|
import { ArrowSquareOut } from "phosphor-svelte";
|
|
27
27
|
import { cn } from "../../lib/utils.js";
|
|
28
|
+
import { safeHref } from "../../lib/safe-href.js";
|
|
28
29
|
import { buttonVariants } from "../../shadcn/button/index.js";
|
|
29
30
|
|
|
30
31
|
// =========================
|
|
@@ -78,7 +79,7 @@
|
|
|
78
79
|
onkeydown={(e) => e.stopPropagation()}
|
|
79
80
|
>
|
|
80
81
|
<a
|
|
81
|
-
{href}
|
|
82
|
+
href={safeHref(href)}
|
|
82
83
|
target="_blank"
|
|
83
84
|
rel="noopener noreferrer"
|
|
84
85
|
aria-label={action}
|
|
@@ -57,6 +57,8 @@
|
|
|
57
57
|
selected?: Set<string>;
|
|
58
58
|
selectAllLabel?: string;
|
|
59
59
|
selectRowLabel?: (row: Row) => string;
|
|
60
|
+
/** The hidden head of the last column, the one holding each row's link or button. */
|
|
61
|
+
openColumnLabel?: string;
|
|
60
62
|
/** The items of a row menu: `ContextMenu.Item`s. Right-click, a long press, Shift+F10 or the ContextMenu key opens it. */
|
|
61
63
|
rowMenu?: Snippet<[Row]>;
|
|
62
64
|
/** Rows that return the same key sit under one header row, and `null` rows sit with no header. Order `rows` so a group is contiguous. */
|
|
@@ -105,6 +107,7 @@
|
|
|
105
107
|
selected = $bindable(new Set<string>()),
|
|
106
108
|
selectAllLabel = "Select all rows",
|
|
107
109
|
selectRowLabel,
|
|
110
|
+
openColumnLabel = "Open",
|
|
108
111
|
rowMenu,
|
|
109
112
|
group,
|
|
110
113
|
groupHeader,
|
|
@@ -408,7 +411,7 @@
|
|
|
408
411
|
{/if}
|
|
409
412
|
{/each}
|
|
410
413
|
{#if opens}
|
|
411
|
-
<th class="w-0 p-0"><span class="sr-only">
|
|
414
|
+
<th class="w-0 p-0"><span class="sr-only">{openColumnLabel}</span></th>
|
|
412
415
|
{/if}
|
|
413
416
|
</Table.Row>
|
|
414
417
|
</Table.Header>
|
|
@@ -46,6 +46,8 @@ declare function $$render<Row extends object>(): {
|
|
|
46
46
|
selected?: Set<string>;
|
|
47
47
|
selectAllLabel?: string;
|
|
48
48
|
selectRowLabel?: (row: Row) => string;
|
|
49
|
+
/** The hidden head of the last column, the one holding each row's link or button. */
|
|
50
|
+
openColumnLabel?: string;
|
|
49
51
|
/** The items of a row menu: `ContextMenu.Item`s. Right-click, a long press, Shift+F10 or the ContextMenu key opens it. */
|
|
50
52
|
rowMenu?: Snippet<[Row]>;
|
|
51
53
|
/** Rows that return the same key sit under one header row, and `null` rows sit with no header. Order `rows` so a group is contiguous. */
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
} from "../thread/index.js";
|
|
39
39
|
import InboxCard from "../work/inbox/inbox-card.svelte";
|
|
40
40
|
import { whenOf } from "../work/inbox/inbox-logic.js";
|
|
41
|
+
import { safeHref } from "../lib/safe-href.js";
|
|
41
42
|
import type { InboxItem, InboxVerdict } from "../work/inbox/inbox-types.js";
|
|
42
43
|
import {
|
|
43
44
|
NO_FILTER,
|
|
@@ -490,8 +491,8 @@
|
|
|
490
491
|
<Check aria-hidden="true" />{labels.clear}
|
|
491
492
|
</Button>
|
|
492
493
|
{/if}
|
|
493
|
-
{#if current.subject.href}
|
|
494
|
-
<Button size="sm" variant="ghost" href={current.subject.href}>
|
|
494
|
+
{#if safeHref(current.subject.href)}
|
|
495
|
+
<Button size="sm" variant="ghost" href={safeHref(current.subject.href)}>
|
|
495
496
|
<ArrowSquareOut aria-hidden="true" />{labels.open}
|
|
496
497
|
</Button>
|
|
497
498
|
{/if}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test";
|
|
2
|
+
import { safeHref } from "./safe-href.js";
|
|
3
|
+
|
|
4
|
+
describe("safeHref", () => {
|
|
5
|
+
it.each([
|
|
6
|
+
"/clients/42",
|
|
7
|
+
"#notes",
|
|
8
|
+
"https://example.com/a?b=1",
|
|
9
|
+
"mailto:hi@example.com",
|
|
10
|
+
"tel:+33123456789",
|
|
11
|
+
"descript://project/abc",
|
|
12
|
+
])("keeps %s, which runs no script", (href) => {
|
|
13
|
+
expect(safeHref(href)).toBe(href);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// The record url finding's payload, and the spellings the URL parser reads as the same scheme.
|
|
17
|
+
it.each([
|
|
18
|
+
"javascript:alert(document.domain)",
|
|
19
|
+
"JavaScript:alert(1)",
|
|
20
|
+
" javascript:alert(1)",
|
|
21
|
+
"java\tscript:alert(1)",
|
|
22
|
+
"java\nscript:alert(1)",
|
|
23
|
+
"\u0001javascript:alert(1)",
|
|
24
|
+
"vbscript:msgbox(1)",
|
|
25
|
+
])("drops %j, which runs script when followed", (href) => {
|
|
26
|
+
expect(safeHref(href)).toBeUndefined();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("treats a missing href as no link", () => {
|
|
30
|
+
expect(safeHref(null)).toBeUndefined();
|
|
31
|
+
expect(safeHref(undefined)).toBeUndefined();
|
|
32
|
+
expect(safeHref("")).toBeUndefined();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// WHY: Svelte writes an href into the page as given, and following an <a href="javascript:...">
|
|
2
|
+
// (or setting location to one) runs that script in the page's origin, with the viewer's session.
|
|
3
|
+
const SCRIPT_SCHEME = /^(?:javascript|vbscript):/i;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The href as given, or undefined when following it would run script. App routes, https,
|
|
7
|
+
* mailto and app schemes like descript: pass, so a link a component builds from data stays a link.
|
|
8
|
+
*/
|
|
9
|
+
export function safeHref(href: string | null | undefined): string | undefined {
|
|
10
|
+
if (!href) return undefined;
|
|
11
|
+
// WHY: the URL parser drops tabs, newlines and leading spaces or control bytes, so " java\tscript:" is javascript:.
|
|
12
|
+
const probe = [...href].filter((c) => c.charCodeAt(0) > 32 && c.charCodeAt(0) !== 127).join("");
|
|
13
|
+
return SCRIPT_SCHEME.test(probe) ? undefined : href;
|
|
14
|
+
}
|
package/src/record/fields.ts
CHANGED
|
@@ -15,6 +15,18 @@ function hasOptions(field: FieldDef): field is Extract<FieldDef, { type: "choice
|
|
|
15
15
|
|
|
16
16
|
export type FieldValues = Record<string, FieldValue | undefined>;
|
|
17
17
|
|
|
18
|
+
const WEB_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);
|
|
19
|
+
|
|
20
|
+
/** A url field's link: http, https or mailto only. Anything else, javascript: above all, shows as text. */
|
|
21
|
+
function webLink(text: string): string | undefined {
|
|
22
|
+
try {
|
|
23
|
+
const url = new URL(text);
|
|
24
|
+
return WEB_PROTOCOLS.has(url.protocol) ? url.href : undefined;
|
|
25
|
+
} catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
18
30
|
function isEmpty(value: FieldValue | undefined): boolean {
|
|
19
31
|
return value == null || value === "" || (Array.isArray(value) && value.length === 0);
|
|
20
32
|
}
|
|
@@ -144,7 +156,7 @@ export function fieldRow(
|
|
|
144
156
|
row.value = String(value)
|
|
145
157
|
.replace(/^https?:\/\/(www\.)?/, "")
|
|
146
158
|
.replace(/\/$/, "");
|
|
147
|
-
row.href = String(value);
|
|
159
|
+
row.href = webLink(String(value));
|
|
148
160
|
break;
|
|
149
161
|
case "email":
|
|
150
162
|
row.value = String(value);
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { TagsInput } from "../components/tags-input/index.js";
|
|
15
15
|
import StatusBadge from "../status/status-badge.svelte";
|
|
16
16
|
import { resolveLabels, type InfoRow, type RecordLabels } from "./types.js";
|
|
17
|
+
import { safeHref } from "../lib/safe-href.js";
|
|
17
18
|
|
|
18
19
|
// =========================
|
|
19
20
|
// PROPS
|
|
@@ -171,9 +172,9 @@
|
|
|
171
172
|
<StatusBadge tone={row.tone} capitalize={false} class="w-fit max-w-full"
|
|
172
173
|
>{row.value}</StatusBadge
|
|
173
174
|
>
|
|
174
|
-
{:else if row.href && row.value}
|
|
175
|
+
{:else if safeHref(row.href) && row.value}
|
|
175
176
|
<a
|
|
176
|
-
href={row.href}
|
|
177
|
+
href={safeHref(row.href)}
|
|
177
178
|
title={row.value}
|
|
178
179
|
class={cn(
|
|
179
180
|
"min-w-0 truncate text-sm font-medium underline-offset-2 hover:underline",
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { flushSync, mount, unmount } from "svelte";
|
|
2
|
+
import { describe, expect, it } from "vite-plus/test";
|
|
3
|
+
import InfoList from "./info-list.svelte";
|
|
4
|
+
|
|
5
|
+
describe("InfoList", () => {
|
|
6
|
+
// A host can fill InfoRow.href itself, so the list checks the scheme at the anchor too.
|
|
7
|
+
it("draws a javascript: href as plain text and keeps an https one a link", () => {
|
|
8
|
+
const target = document.createElement("div");
|
|
9
|
+
document.body.appendChild(target);
|
|
10
|
+
const app = mount(InfoList, {
|
|
11
|
+
target,
|
|
12
|
+
props: {
|
|
13
|
+
rows: [
|
|
14
|
+
{ label: "Website", value: "click me", href: "javascript:alert(document.domain)" },
|
|
15
|
+
{ label: "Docs", value: "example.com", href: "https://example.com/" },
|
|
16
|
+
],
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
flushSync();
|
|
20
|
+
const hrefs = [...target.querySelectorAll("a")].map((a) => a.getAttribute("href"));
|
|
21
|
+
expect(hrefs).toEqual(["https://example.com/"]);
|
|
22
|
+
expect(target.textContent).toContain("click me");
|
|
23
|
+
void unmount(app);
|
|
24
|
+
target.remove();
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { formatRange } from "./format.js";
|
|
11
11
|
import { todayIso } from "../work/dates.js";
|
|
12
12
|
import { resolveLabels, type RecordLabels, type RecordNext } from "./types.js";
|
|
13
|
+
import { safeHref } from "../lib/safe-href.js";
|
|
13
14
|
|
|
14
15
|
// =========================
|
|
15
16
|
// PROPS
|
|
@@ -61,8 +62,8 @@
|
|
|
61
62
|
>{when}</span
|
|
62
63
|
>
|
|
63
64
|
{/if}
|
|
64
|
-
{#if next.href}
|
|
65
|
-
<a href={next.href} class="min-w-0 truncate underline-offset-2 hover:underline"
|
|
65
|
+
{#if safeHref(next.href)}
|
|
66
|
+
<a href={safeHref(next.href)} class="min-w-0 truncate underline-offset-2 hover:underline"
|
|
66
67
|
>{next.label}</a
|
|
67
68
|
>
|
|
68
69
|
{:else}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// IMPORTS
|
|
4
4
|
// =========================
|
|
5
5
|
import { cn } from "../lib/utils.js";
|
|
6
|
+
import { safeHref } from "../lib/safe-href.js";
|
|
6
7
|
import { Progress } from "../shadcn/progress/index.js";
|
|
7
8
|
import { TONE_TEXT } from "../lib/tone.js";
|
|
8
9
|
import StatusBadge from "../status/status-badge.svelte";
|
|
@@ -115,10 +116,11 @@
|
|
|
115
116
|
<ul class={cn("flex flex-col gap-1", className)}>
|
|
116
117
|
{#each projects as project (project.id)}
|
|
117
118
|
{@const active = project.id === activeId}
|
|
119
|
+
{@const href = safeHref(project.href)}
|
|
118
120
|
<li>
|
|
119
|
-
{#if
|
|
121
|
+
{#if href}
|
|
120
122
|
<a
|
|
121
|
-
|
|
123
|
+
{href}
|
|
122
124
|
aria-current={active ? "page" : undefined}
|
|
123
125
|
class={cn(
|
|
124
126
|
"flex flex-col gap-1.5 rounded-lg px-2 py-2 transition-colors any-pointer-coarse:py-3",
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import type { Tone } from "../lib/tone.js";
|
|
9
9
|
import StatusBadge from "../status/status-badge.svelte";
|
|
10
10
|
import { gutterClass } from "./gutter.js";
|
|
11
|
+
import { safeHref } from "../lib/safe-href.js";
|
|
11
12
|
|
|
12
13
|
// =========================
|
|
13
14
|
// PROPS
|
|
@@ -45,7 +46,7 @@
|
|
|
45
46
|
<header class={cn("flex flex-col gap-2 border-b border-border py-3", gutterClass, className)}>
|
|
46
47
|
{#if parent}
|
|
47
48
|
<a
|
|
48
|
-
href={parent.href}
|
|
49
|
+
href={safeHref(parent.href)}
|
|
49
50
|
class="-ml-1 inline-flex min-h-6 w-fit max-w-full items-center gap-0.5 rounded-sm text-sm text-muted-foreground transition-colors hover:text-foreground any-pointer-coarse:min-h-11"
|
|
50
51
|
>
|
|
51
52
|
<CaretLeft class="size-3.5 shrink-0" aria-hidden="true" />
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { DEFAULT_RECORD_FILTERS, DEFAULT_RECORD_KINDS } from "./kinds.js";
|
|
15
15
|
import { formatDayHeader, formatMoney, formatTime } from "./format.js";
|
|
16
16
|
import { measureClamp } from "./measure-clamp.js";
|
|
17
|
+
import { safeHref } from "../lib/safe-href.js";
|
|
17
18
|
import {
|
|
18
19
|
dayRelation,
|
|
19
20
|
eventsForFilter,
|
|
@@ -172,9 +173,9 @@
|
|
|
172
173
|
{@render custom(event)}
|
|
173
174
|
{:else}
|
|
174
175
|
<p class="text-sm leading-snug font-medium wrap-anywhere">
|
|
175
|
-
{#if event.href}
|
|
176
|
+
{#if safeHref(event.href)}
|
|
176
177
|
<a
|
|
177
|
-
href={event.href}
|
|
178
|
+
href={safeHref(event.href)}
|
|
178
179
|
class="-my-1 inline-block py-1 underline-offset-2 hover:underline"
|
|
179
180
|
>{event.title}</a
|
|
180
181
|
>
|
|
@@ -235,7 +236,7 @@
|
|
|
235
236
|
{#if showProject && event.projectName}
|
|
236
237
|
<Badge
|
|
237
238
|
variant="outline"
|
|
238
|
-
href={event.projectHref
|
|
239
|
+
href={safeHref(event.projectHref)}
|
|
239
240
|
class="max-w-48 truncate"
|
|
240
241
|
>
|
|
241
242
|
{event.projectName}
|
|
@@ -423,6 +423,25 @@ describe("fields", () => {
|
|
|
423
423
|
expect(saved).toEqual([["budget", 9000]]);
|
|
424
424
|
});
|
|
425
425
|
|
|
426
|
+
// The audit's finding: a url field rendered whatever scheme an editor saved as a live link.
|
|
427
|
+
it("links a url field only to http, https or mailto, and shows anything else as text", () => {
|
|
428
|
+
const site: FieldDef = { key: "site", label: "Website", type: "url" };
|
|
429
|
+
expect(fieldRow(site, "https://www.example.com/").href).toBe("https://www.example.com/");
|
|
430
|
+
expect(fieldRow(site, "http://example.com").href).toBe("http://example.com/");
|
|
431
|
+
expect(fieldRow(site, "mailto:hi@example.com").href).toBe("mailto:hi@example.com");
|
|
432
|
+
for (const hostile of [
|
|
433
|
+
"javascript:alert(document.domain)",
|
|
434
|
+
" javascript:alert(1)",
|
|
435
|
+
"java\tscript:alert(1)",
|
|
436
|
+
"data:text/html,<script>alert(1)</script>",
|
|
437
|
+
"vbscript:msgbox(1)",
|
|
438
|
+
]) {
|
|
439
|
+
const row = fieldRow(site, hostile);
|
|
440
|
+
expect(row.href).toBeUndefined();
|
|
441
|
+
expect(row.value).toBe(hostile);
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
|
|
426
445
|
it("leaves a row read-only when no onSave is given, and empty values undrawn", () => {
|
|
427
446
|
const row = fieldRow(size, null);
|
|
428
447
|
expect(row.onSave).toBeUndefined();
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { formatAge } from "./format.js";
|
|
12
12
|
import { measureClamp } from "./measure-clamp.js";
|
|
13
13
|
import { resolveLabels, type RecordLabels } from "./types.js";
|
|
14
|
+
import { safeHref } from "../lib/safe-href.js";
|
|
14
15
|
|
|
15
16
|
// =========================
|
|
16
17
|
// PROPS
|
|
@@ -74,9 +75,9 @@
|
|
|
74
75
|
>
|
|
75
76
|
<span class="font-medium text-foreground">{t.whereWeAre}</span>
|
|
76
77
|
{#if project}
|
|
77
|
-
{#if project.href}
|
|
78
|
+
{#if safeHref(project.href)}
|
|
78
79
|
<a
|
|
79
|
-
href={project.href}
|
|
80
|
+
href={safeHref(project.href)}
|
|
80
81
|
class="font-medium text-foreground underline-offset-2 hover:underline"
|
|
81
82
|
>{project.name}</a
|
|
82
83
|
>
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
let {
|
|
7
7
|
ref = $bindable(null),
|
|
8
8
|
class: className,
|
|
9
|
+
// Inside a form, a bare button submits it: Locameublé's Annuler archived the bien (TSK-2744).
|
|
10
|
+
type = "button",
|
|
9
11
|
...restProps
|
|
10
12
|
}: AlertDialogPrimitive.CancelProps = $props();
|
|
11
13
|
</script>
|
|
@@ -13,6 +15,7 @@
|
|
|
13
15
|
<AlertDialogPrimitive.Cancel
|
|
14
16
|
bind:ref
|
|
15
17
|
data-slot="alert-dialog-cancel"
|
|
18
|
+
{type}
|
|
16
19
|
class={cn(buttonVariants({ variant: "outline" }), className)}
|
|
17
20
|
{...restProps}
|
|
18
21
|
/>
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
title = "Command Palette",
|
|
13
13
|
description = "Search for a command to run...",
|
|
14
14
|
showCloseButton = false,
|
|
15
|
+
closeLabel = "Close",
|
|
15
16
|
portalProps,
|
|
16
17
|
children,
|
|
17
18
|
class: className,
|
|
@@ -23,6 +24,8 @@
|
|
|
23
24
|
title?: string;
|
|
24
25
|
description?: string;
|
|
25
26
|
showCloseButton?: boolean;
|
|
27
|
+
/** What a screen reader calls the close button, in the app's language. */
|
|
28
|
+
closeLabel?: string;
|
|
26
29
|
class?: string;
|
|
27
30
|
} = $props();
|
|
28
31
|
</script>
|
|
@@ -51,6 +54,7 @@
|
|
|
51
54
|
data-command-dialog="true"
|
|
52
55
|
overlayClass="t-instant"
|
|
53
56
|
{showCloseButton}
|
|
57
|
+
{closeLabel}
|
|
54
58
|
{portalProps}
|
|
55
59
|
>
|
|
56
60
|
<!-- WHY the root's opaque `bg-popover`, no glass: `.t-modal` on the content sets `will-change:
|
|
@@ -7,6 +7,8 @@ type $$ComponentProps = WithoutChildrenOrChild<DialogPrimitive.RootProps> & With
|
|
|
7
7
|
title?: string;
|
|
8
8
|
description?: string;
|
|
9
9
|
showCloseButton?: boolean;
|
|
10
|
+
/** What a screen reader calls the close button, in the app's language. */
|
|
11
|
+
closeLabel?: string;
|
|
10
12
|
class?: string;
|
|
11
13
|
};
|
|
12
14
|
declare const $$Component: import("svelte").Component<$$ComponentProps, {}, "value" | "ref" | "open">;
|
|
@@ -14,12 +14,15 @@
|
|
|
14
14
|
portalProps,
|
|
15
15
|
children,
|
|
16
16
|
showCloseButton = true,
|
|
17
|
+
closeLabel = "Close",
|
|
17
18
|
overlayClass,
|
|
18
19
|
...restProps
|
|
19
20
|
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
|
|
20
21
|
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DialogPortal>>;
|
|
21
22
|
children: Snippet;
|
|
22
23
|
showCloseButton?: boolean;
|
|
24
|
+
/** What a screen reader calls the built-in close button, in the app's language. */
|
|
25
|
+
closeLabel?: string;
|
|
23
26
|
/** Classes for the backdrop this dialog mounts, which is its sibling in the
|
|
24
27
|
* portal and so cannot be reached from the content's own class. */
|
|
25
28
|
overlayClass?: string;
|
|
@@ -62,7 +65,7 @@
|
|
|
62
65
|
{...props}
|
|
63
66
|
>
|
|
64
67
|
<X aria-hidden="true" size={16} />
|
|
65
|
-
<span class="sr-only">
|
|
68
|
+
<span class="sr-only">{closeLabel}</span>
|
|
66
69
|
</Button>
|
|
67
70
|
{/snippet}
|
|
68
71
|
</DialogPrimitive.Close>
|
|
@@ -7,6 +7,8 @@ type $$ComponentProps = WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
|
|
|
7
7
|
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DialogPortal>>;
|
|
8
8
|
children: Snippet;
|
|
9
9
|
showCloseButton?: boolean;
|
|
10
|
+
/** What a screen reader calls the built-in close button, in the app's language. */
|
|
11
|
+
closeLabel?: string;
|
|
10
12
|
/** Classes for the backdrop this dialog mounts, which is its sibling in the
|
|
11
13
|
* portal and so cannot be reached from the content's own class. */
|
|
12
14
|
overlayClass?: string;
|
|
@@ -9,10 +9,13 @@
|
|
|
9
9
|
class: className,
|
|
10
10
|
children,
|
|
11
11
|
showCloseButton = false,
|
|
12
|
+
closeLabel = "Close",
|
|
12
13
|
band = false,
|
|
13
14
|
...restProps
|
|
14
15
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
|
15
16
|
showCloseButton?: boolean;
|
|
17
|
+
/** The close button's text, in the app's language. */
|
|
18
|
+
closeLabel?: string;
|
|
16
19
|
/**
|
|
17
20
|
* Draw the footer as a band: a hairline on top and a quiet surface out to both edges
|
|
18
21
|
* and the bottom corners. It cancels the content's `--dialog-pad` with a negative
|
|
@@ -43,7 +46,7 @@
|
|
|
43
46
|
{#if showCloseButton}
|
|
44
47
|
<DialogPrimitive.Close>
|
|
45
48
|
{#snippet child({ props })}
|
|
46
|
-
<Button variant="outline" {...props}>
|
|
49
|
+
<Button variant="outline" {...props}>{closeLabel}</Button>
|
|
47
50
|
{/snippet}
|
|
48
51
|
</DialogPrimitive.Close>
|
|
49
52
|
{/if}
|
|
@@ -2,6 +2,8 @@ import { type WithElementRef } from "../../lib/utils.js";
|
|
|
2
2
|
import type { HTMLAttributes } from "svelte/elements";
|
|
3
3
|
type $$ComponentProps = WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
|
4
4
|
showCloseButton?: boolean;
|
|
5
|
+
/** The close button's text, in the app's language. */
|
|
6
|
+
closeLabel?: string;
|
|
5
7
|
/**
|
|
6
8
|
* Draw the footer as a band: a hairline on top and a quiet surface out to both edges
|
|
7
9
|
* and the bottom corners. It cancels the content's `--dialog-pad` with a negative
|
|
@@ -14,12 +14,26 @@
|
|
|
14
14
|
variant?: "lock" | "obfuscate";
|
|
15
15
|
value?: string;
|
|
16
16
|
class?: string;
|
|
17
|
+
/** The reveal button's name while the value is hidden, in the app's language. */
|
|
18
|
+
revealLabel?: string;
|
|
19
|
+
/** The same button's name while the value shows. */
|
|
20
|
+
hideLabel?: string;
|
|
21
|
+
/** The name of the `obfuscate` box. */
|
|
22
|
+
maskedLabel?: string;
|
|
17
23
|
};
|
|
18
24
|
|
|
19
25
|
// =========================
|
|
20
26
|
// PROPS
|
|
21
27
|
// =========================
|
|
22
|
-
let {
|
|
28
|
+
let {
|
|
29
|
+
variant = "lock",
|
|
30
|
+
value = $bindable(""),
|
|
31
|
+
class: className,
|
|
32
|
+
revealLabel = "Reveal value",
|
|
33
|
+
hideLabel = "Hide value",
|
|
34
|
+
maskedLabel = "Encrypted value",
|
|
35
|
+
...rest
|
|
36
|
+
}: Props = $props();
|
|
23
37
|
|
|
24
38
|
// =========================
|
|
25
39
|
// STATE
|
|
@@ -46,7 +60,7 @@
|
|
|
46
60
|
"border-input bg-muted/30 text-muted-foreground flex h-8 w-full items-center gap-2 rounded-lg border px-2.5 py-1 font-mono text-sm",
|
|
47
61
|
className,
|
|
48
62
|
)}
|
|
49
|
-
aria-label=
|
|
63
|
+
aria-label={maskedLabel}
|
|
50
64
|
>
|
|
51
65
|
<LockKey aria-hidden="true" class="text-muted-foreground/70 size-3.5 shrink-0" />
|
|
52
66
|
<span class="truncate">{masked || "••••••••"}</span>
|
|
@@ -61,7 +75,7 @@
|
|
|
61
75
|
/>
|
|
62
76
|
<button
|
|
63
77
|
type="button"
|
|
64
|
-
aria-label={revealed ?
|
|
78
|
+
aria-label={revealed ? hideLabel : revealLabel}
|
|
65
79
|
class="text-muted-foreground hover:text-foreground absolute inset-y-0 right-0 flex w-8 items-center justify-center rounded-r-lg transition-colors"
|
|
66
80
|
onclick={() => (revealed = !revealed)}
|
|
67
81
|
>
|
|
@@ -3,6 +3,12 @@ type Props = Omit<HTMLInputAttributes, "type" | "value" | "class" | "files"> & {
|
|
|
3
3
|
variant?: "lock" | "obfuscate";
|
|
4
4
|
value?: string;
|
|
5
5
|
class?: string;
|
|
6
|
+
/** The reveal button's name while the value is hidden, in the app's language. */
|
|
7
|
+
revealLabel?: string;
|
|
8
|
+
/** The same button's name while the value shows. */
|
|
9
|
+
hideLabel?: string;
|
|
10
|
+
/** The name of the `obfuscate` box. */
|
|
11
|
+
maskedLabel?: string;
|
|
6
12
|
};
|
|
7
13
|
declare const $$Component: import("svelte").Component<Props, {}, "value">;
|
|
8
14
|
type $$Component = ReturnType<typeof $$Component>;
|
|
@@ -14,12 +14,20 @@
|
|
|
14
14
|
variant = "outline",
|
|
15
15
|
size = "sm",
|
|
16
16
|
onclick,
|
|
17
|
+
label,
|
|
17
18
|
...restProps
|
|
18
19
|
}: MessageScrollerPrimitive.ButtonProps & {
|
|
19
20
|
variant?: ButtonVariant;
|
|
20
21
|
size?: ButtonSize;
|
|
22
|
+
/**
|
|
23
|
+
* What a screen reader calls the icon-only button, in the app's language.
|
|
24
|
+
* Defaults to "Scroll to end", or "Scroll to start" when `direction="start"`.
|
|
25
|
+
*/
|
|
26
|
+
label?: string;
|
|
21
27
|
} = $props();
|
|
22
28
|
|
|
29
|
+
const srLabel = $derived(label ?? (direction === "end" ? "Scroll to end" : "Scroll to start"));
|
|
30
|
+
|
|
23
31
|
// The primitive blurs the button and it turns inert at the end, dropping focus to the page. Focus goes to the thread instead.
|
|
24
32
|
function handleClick(event: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }) {
|
|
25
33
|
onclick?.(event);
|
|
@@ -54,9 +62,7 @@
|
|
|
54
62
|
{@render children()}
|
|
55
63
|
{:else}
|
|
56
64
|
<ArrowDown aria-hidden="true" />
|
|
57
|
-
<span class="sr-only">
|
|
58
|
-
{direction === "end" ? "Scroll to end" : "Scroll to start"}
|
|
59
|
-
</span>
|
|
65
|
+
<span class="sr-only">{srLabel}</span>
|
|
60
66
|
{/if}
|
|
61
67
|
</Button>
|
|
62
68
|
{/if}
|
|
@@ -3,6 +3,11 @@ import type { ButtonSize, ButtonVariant } from "../button/index.js";
|
|
|
3
3
|
type $$ComponentProps = MessageScrollerPrimitive.ButtonProps & {
|
|
4
4
|
variant?: ButtonVariant;
|
|
5
5
|
size?: ButtonSize;
|
|
6
|
+
/**
|
|
7
|
+
* What a screen reader calls the icon-only button, in the app's language.
|
|
8
|
+
* Defaults to "Scroll to end", or "Scroll to start" when `direction="start"`.
|
|
9
|
+
*/
|
|
10
|
+
label?: string;
|
|
6
11
|
};
|
|
7
12
|
declare const $$Component: import("svelte").Component<$$ComponentProps, {}, "ref">;
|
|
8
13
|
type $$Component = ReturnType<typeof $$Component>;
|
|
@@ -7,8 +7,12 @@
|
|
|
7
7
|
let {
|
|
8
8
|
ref = $bindable(null),
|
|
9
9
|
class: className,
|
|
10
|
+
label = "Next",
|
|
10
11
|
...restProps
|
|
11
|
-
}: PaginationPrimitive.NextButtonProps
|
|
12
|
+
}: PaginationPrimitive.NextButtonProps & {
|
|
13
|
+
/** The text beside the arrow from `sm` up. Name the button with `aria-label`. */
|
|
14
|
+
label?: string;
|
|
15
|
+
} = $props();
|
|
12
16
|
</script>
|
|
13
17
|
|
|
14
18
|
<PaginationPrimitive.NextButton
|
|
@@ -17,7 +21,7 @@
|
|
|
17
21
|
class={cn(buttonVariants({ variant: "ghost", size: "default" }), "pr-2!", className)}
|
|
18
22
|
{...restProps}
|
|
19
23
|
>
|
|
20
|
-
<span class="hidden sm:block">
|
|
24
|
+
<span class="hidden sm:block">{label}</span>
|
|
21
25
|
<CaretRight
|
|
22
26
|
aria-hidden="true"
|
|
23
27
|
data-icon="inline-end"
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { Pagination as PaginationPrimitive } from "bits-ui";
|
|
2
|
-
|
|
2
|
+
type $$ComponentProps = PaginationPrimitive.NextButtonProps & {
|
|
3
|
+
/** The text beside the arrow from `sm` up. Name the button with `aria-label`. */
|
|
4
|
+
label?: string;
|
|
5
|
+
};
|
|
6
|
+
declare const $$Component: import("svelte").Component<$$ComponentProps, {}, "ref">;
|
|
3
7
|
type $$Component = ReturnType<typeof $$Component>;
|
|
4
8
|
export default $$Component;
|
|
@@ -7,8 +7,12 @@
|
|
|
7
7
|
let {
|
|
8
8
|
ref = $bindable(null),
|
|
9
9
|
class: className,
|
|
10
|
+
label = "Previous",
|
|
10
11
|
...restProps
|
|
11
|
-
}: PaginationPrimitive.PrevButtonProps
|
|
12
|
+
}: PaginationPrimitive.PrevButtonProps & {
|
|
13
|
+
/** The text beside the arrow from `sm` up. Name the button with `aria-label`. */
|
|
14
|
+
label?: string;
|
|
15
|
+
} = $props();
|
|
12
16
|
</script>
|
|
13
17
|
|
|
14
18
|
<PaginationPrimitive.PrevButton
|
|
@@ -26,5 +30,5 @@
|
|
|
26
30
|
data-icon="inline-start"
|
|
27
31
|
class="cn-rtl-flip"
|
|
28
32
|
/>
|
|
29
|
-
<span class="hidden sm:block">
|
|
33
|
+
<span class="hidden sm:block">{label}</span>
|
|
30
34
|
</PaginationPrimitive.PrevButton>
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { Pagination as PaginationPrimitive } from "bits-ui";
|
|
2
|
-
|
|
2
|
+
type $$ComponentProps = PaginationPrimitive.PrevButtonProps & {
|
|
3
|
+
/** The text beside the arrow from `sm` up. Name the button with `aria-label`. */
|
|
4
|
+
label?: string;
|
|
5
|
+
};
|
|
6
|
+
declare const $$Component: import("svelte").Component<$$ComponentProps, {}, "ref">;
|
|
3
7
|
type $$Component = ReturnType<typeof $$Component>;
|
|
4
8
|
export default $$Component;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// An open Sheet and Dialog, for the close-label test.
|
|
3
|
+
import * as Sheet from "./index.js";
|
|
4
|
+
import * as Dialog from "../dialog/index.js";
|
|
5
|
+
|
|
6
|
+
let { closeLabel }: { closeLabel?: string } = $props();
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<Sheet.Root open>
|
|
10
|
+
<Sheet.Content {closeLabel}>
|
|
11
|
+
<Sheet.Title>Sheet</Sheet.Title>
|
|
12
|
+
</Sheet.Content>
|
|
13
|
+
</Sheet.Root>
|
|
14
|
+
|
|
15
|
+
<Dialog.Root open>
|
|
16
|
+
<Dialog.Content {closeLabel}>
|
|
17
|
+
<Dialog.Title>Dialog</Dialog.Title>
|
|
18
|
+
<Dialog.Footer showCloseButton {closeLabel} />
|
|
19
|
+
</Dialog.Content>
|
|
20
|
+
</Dialog.Root>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { flushSync, mount, unmount } from "svelte";
|
|
2
|
+
import { describe, expect, it } from "vite-plus/test";
|
|
3
|
+
import Overlays from "./close-label.test.svelte";
|
|
4
|
+
import SecretInput from "../input/secret-input.svelte";
|
|
5
|
+
|
|
6
|
+
// A French app's Assistant panel announced "Close" (TSK-2735): each built-in label takes a prop.
|
|
7
|
+
function closeNames(closeLabel?: string): string[] {
|
|
8
|
+
const app = mount(Overlays, { target: document.body, props: closeLabel ? { closeLabel } : {} });
|
|
9
|
+
flushSync();
|
|
10
|
+
const names = [
|
|
11
|
+
...document.querySelectorAll(
|
|
12
|
+
'[data-slot="sheet-close"], [data-slot="dialog-close"], [data-slot="dialog-footer"] button',
|
|
13
|
+
),
|
|
14
|
+
].map((b) => b.textContent?.trim() ?? "");
|
|
15
|
+
void unmount(app);
|
|
16
|
+
return names;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("built-in close buttons", () => {
|
|
20
|
+
it("say Close by default", () => {
|
|
21
|
+
expect(closeNames()).toEqual(["Close", "Close", "Close"]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("say the caller's closeLabel", () => {
|
|
25
|
+
expect(closeNames("Fermer")).toEqual(["Fermer", "Fermer", "Fermer"]);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("SecretInput", () => {
|
|
30
|
+
it("names its reveal button in the caller's words", () => {
|
|
31
|
+
const target = document.createElement("div");
|
|
32
|
+
const app = mount(SecretInput, {
|
|
33
|
+
target,
|
|
34
|
+
props: { revealLabel: "Afficher", hideLabel: "Masquer" },
|
|
35
|
+
});
|
|
36
|
+
flushSync();
|
|
37
|
+
const button = target.querySelector("button");
|
|
38
|
+
expect(button?.getAttribute("aria-label")).toBe("Afficher");
|
|
39
|
+
button?.click();
|
|
40
|
+
flushSync();
|
|
41
|
+
expect(button?.getAttribute("aria-label")).toBe("Masquer");
|
|
42
|
+
void unmount(app);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
class: className,
|
|
20
20
|
side = "right",
|
|
21
21
|
showCloseButton = true,
|
|
22
|
+
closeLabel = "Close",
|
|
22
23
|
floating,
|
|
23
24
|
portalProps,
|
|
24
25
|
children,
|
|
@@ -27,6 +28,8 @@
|
|
|
27
28
|
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SheetPortal>>;
|
|
28
29
|
side?: Side;
|
|
29
30
|
showCloseButton?: boolean;
|
|
31
|
+
/** What a screen reader calls the built-in close button, in the app's language. */
|
|
32
|
+
closeLabel?: string;
|
|
30
33
|
/**
|
|
31
34
|
* From `sm` up a left or right sheet floats 8px off the edge, like SidePanel;
|
|
32
35
|
* `floating={false}` keeps it flush. A phone stays full bleed for the width.
|
|
@@ -82,7 +85,7 @@
|
|
|
82
85
|
{#snippet child({ props })}
|
|
83
86
|
<Button variant="ghost" class="absolute top-3 right-2" size="sm" icon {...props}>
|
|
84
87
|
<X aria-hidden="true" size={16} />
|
|
85
|
-
<span class="sr-only">
|
|
88
|
+
<span class="sr-only">{closeLabel}</span>
|
|
86
89
|
</Button>
|
|
87
90
|
{/snippet}
|
|
88
91
|
</SheetPrimitive.Close>
|
|
@@ -9,6 +9,8 @@ type $$ComponentProps = WithoutChildrenOrChild<SheetPrimitive.ContentProps> & {
|
|
|
9
9
|
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SheetPortal>>;
|
|
10
10
|
side?: Side;
|
|
11
11
|
showCloseButton?: boolean;
|
|
12
|
+
/** What a screen reader calls the built-in close button, in the app's language. */
|
|
13
|
+
closeLabel?: string;
|
|
12
14
|
/**
|
|
13
15
|
* From `sm` up a left or right sheet floats 8px off the edge, like SidePanel;
|
|
14
16
|
* `floating={false}` keeps it flush. A phone stays full bleed for the width.
|
|
@@ -7,8 +7,12 @@
|
|
|
7
7
|
ref = $bindable(null),
|
|
8
8
|
class: className,
|
|
9
9
|
children,
|
|
10
|
+
label = "Toggle Sidebar",
|
|
10
11
|
...restProps
|
|
11
|
-
}: WithElementRef<HTMLAttributes<HTMLButtonElement>, HTMLButtonElement>
|
|
12
|
+
}: WithElementRef<HTMLAttributes<HTMLButtonElement>, HTMLButtonElement> & {
|
|
13
|
+
/** The rail's name and hover tooltip, in the app's language. */
|
|
14
|
+
label?: string;
|
|
15
|
+
} = $props();
|
|
12
16
|
|
|
13
17
|
const sidebar = useSidebar();
|
|
14
18
|
</script>
|
|
@@ -17,10 +21,10 @@
|
|
|
17
21
|
bind:this={ref}
|
|
18
22
|
data-sidebar="rail"
|
|
19
23
|
data-slot="sidebar-rail"
|
|
20
|
-
aria-label=
|
|
24
|
+
aria-label={label}
|
|
21
25
|
tabindex={-1}
|
|
22
26
|
onclick={sidebar.toggle}
|
|
23
|
-
title=
|
|
27
|
+
title={label}
|
|
24
28
|
class={cn(
|
|
25
29
|
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-[background-color,transform,inset] duration-[var(--duration-fast)] ease-[var(--ease-smooth-out)] group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
|
|
26
30
|
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { type WithElementRef } from "../../lib/utils.js";
|
|
2
2
|
import type { HTMLAttributes } from "svelte/elements";
|
|
3
|
-
|
|
3
|
+
type $$ComponentProps = WithElementRef<HTMLAttributes<HTMLButtonElement>, HTMLButtonElement> & {
|
|
4
|
+
/** The rail's name and hover tooltip, in the app's language. */
|
|
5
|
+
label?: string;
|
|
6
|
+
};
|
|
7
|
+
declare const $$Component: import("svelte").Component<$$ComponentProps, {}, "ref">;
|
|
4
8
|
type $$Component = ReturnType<typeof $$Component>;
|
|
5
9
|
export default $$Component;
|
|
@@ -9,9 +9,12 @@
|
|
|
9
9
|
ref = $bindable(null),
|
|
10
10
|
class: className,
|
|
11
11
|
onclick,
|
|
12
|
+
label = "Toggle Sidebar",
|
|
12
13
|
...restProps
|
|
13
14
|
}: ComponentProps<typeof Button> & {
|
|
14
15
|
onclick?: (e: MouseEvent) => void;
|
|
16
|
+
/** What a screen reader calls the icon-only button, in the app's language. */
|
|
17
|
+
label?: string;
|
|
15
18
|
} = $props();
|
|
16
19
|
|
|
17
20
|
const sidebar = useSidebar();
|
|
@@ -35,5 +38,5 @@
|
|
|
35
38
|
{...restProps}
|
|
36
39
|
>
|
|
37
40
|
<SidebarSimple aria-hidden="true" size={16} />
|
|
38
|
-
<span class="sr-only">
|
|
41
|
+
<span class="sr-only">{label}</span>
|
|
39
42
|
</Button>
|
|
@@ -2,6 +2,8 @@ import { Button } from "../button/index.js";
|
|
|
2
2
|
import type { ComponentProps } from "svelte";
|
|
3
3
|
type $$ComponentProps = ComponentProps<typeof Button> & {
|
|
4
4
|
onclick?: (e: MouseEvent) => void;
|
|
5
|
+
/** What a screen reader calls the icon-only button, in the app's language. */
|
|
6
|
+
label?: string;
|
|
5
7
|
};
|
|
6
8
|
declare const $$Component: import("svelte").Component<$$ComponentProps, {}, "ref">;
|
|
7
9
|
type $$Component = ReturnType<typeof $$Component>;
|
package/src/thread/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import * as TraceUI from "../shadcn/trace/index.js";
|
|
3
|
+
import { TagChip } from "../components/chip/index.js";
|
|
3
4
|
import type { ThreadEvent } from "./types.js";
|
|
5
|
+
import { safeHref } from "../lib/safe-href.js";
|
|
4
6
|
|
|
5
7
|
interface Props {
|
|
6
8
|
events: ThreadEvent[];
|
|
@@ -8,6 +10,16 @@
|
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
let { events, when }: Props = $props();
|
|
13
|
+
|
|
14
|
+
const TOKEN = "{record}";
|
|
15
|
+
|
|
16
|
+
// The sentence around the chip. No token puts the chip at the end, so an older event with a
|
|
17
|
+
// record and a plain title still reads in order.
|
|
18
|
+
function split(event: ThreadEvent): [string, string] {
|
|
19
|
+
const at = event.title.indexOf(TOKEN);
|
|
20
|
+
if (at < 0) return [`${event.title} `, ""];
|
|
21
|
+
return [event.title.slice(0, at), event.title.slice(at + TOKEN.length)];
|
|
22
|
+
}
|
|
11
23
|
</script>
|
|
12
24
|
|
|
13
25
|
<!-- ps-1 centres each 16px icon in the 24px avatar column the comments use. -->
|
|
@@ -19,9 +31,28 @@
|
|
|
19
31
|
<TraceUI.Icon class="mt-px"><Icon size={14} /></TraceUI.Icon>
|
|
20
32
|
{/if}
|
|
21
33
|
<!-- Wraps, not truncates: a status name can be a sentence, and a touch screen has no hover title. -->
|
|
22
|
-
<TraceUI.Title class="flex-1 whitespace-normal text-pretty [overflow-wrap:anywhere]"
|
|
23
|
-
|
|
24
|
-
|
|
34
|
+
<TraceUI.Title class="flex-1 whitespace-normal text-pretty [overflow-wrap:anywhere]">
|
|
35
|
+
{#if event.record}
|
|
36
|
+
{@const record = event.record}
|
|
37
|
+
{@const [before, after] = split(event)}
|
|
38
|
+
{@const recordHref = safeHref(record.href)}
|
|
39
|
+
{before}{#if recordHref}<a
|
|
40
|
+
href={recordHref}
|
|
41
|
+
class="rounded-sm align-middle outline-none focus-visible:shadow-[var(--ring-focus)]"
|
|
42
|
+
><TagChip>
|
|
43
|
+
{#snippet icon()}{#if record.icon}{@const RecordIcon = record.icon}<RecordIcon
|
|
44
|
+
aria-hidden="true"
|
|
45
|
+
/>{/if}{/snippet}{record.label}</TagChip
|
|
46
|
+
></a
|
|
47
|
+
>{:else}<TagChip class="align-middle">
|
|
48
|
+
{#snippet icon()}{#if record.icon}{@const RecordIcon = record.icon}<RecordIcon
|
|
49
|
+
aria-hidden="true"
|
|
50
|
+
/>{/if}{/snippet}{record.label}</TagChip
|
|
51
|
+
>{/if}{after}
|
|
52
|
+
{:else}
|
|
53
|
+
{event.title}
|
|
54
|
+
{/if}
|
|
55
|
+
</TraceUI.Title>
|
|
25
56
|
<TraceUI.Description>
|
|
26
57
|
<time datetime={event.at}>{when(event)}</time>
|
|
27
58
|
</TraceUI.Description>
|
package/src/thread/types.ts
CHANGED
|
@@ -57,6 +57,16 @@ export type ThreadEvent = {
|
|
|
57
57
|
/** ISO timestamp. */
|
|
58
58
|
at: string;
|
|
59
59
|
icon?: PhosphorIcon;
|
|
60
|
+
/** A record the sentence names, drawn as a chip inside it. Write `{record}` in `title` where
|
|
61
|
+
it goes; without the token it ends the sentence. */
|
|
62
|
+
record?: ThreadEventRecord;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** A linked record inside an activity line: a task, a doc, a thread. */
|
|
66
|
+
export type ThreadEventRecord = {
|
|
67
|
+
label: string;
|
|
68
|
+
href?: string;
|
|
69
|
+
icon?: PhosphorIcon;
|
|
60
70
|
};
|
|
61
71
|
|
|
62
72
|
/** The thread in time order: a conversation, or a run of events between two of them. */
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { Button } from "../shadcn/button/index.js";
|
|
3
3
|
import StatusBadge from "../status/status-badge.svelte";
|
|
4
4
|
import { cn } from "../lib/utils.js";
|
|
5
|
+
import { safeHref } from "../lib/safe-href.js";
|
|
5
6
|
import {
|
|
6
7
|
DEFAULT_TRIAGE_TASK_LABELS,
|
|
7
8
|
type TriageOutcome,
|
|
@@ -72,7 +73,7 @@
|
|
|
72
73
|
<StatusBadge tone={outcome.tone} capitalize={false}>{outcome.label}</StatusBadge>
|
|
73
74
|
{#if task}
|
|
74
75
|
<a
|
|
75
|
-
href={task.url}
|
|
76
|
+
href={safeHref(task.url)}
|
|
76
77
|
target="_blank"
|
|
77
78
|
rel="noreferrer"
|
|
78
79
|
class="text-primary min-w-0 text-xs [overflow-wrap:anywhere] hover:underline"
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
import { FolderSimple } from "phosphor-svelte";
|
|
33
33
|
import { Copied } from "../../components/copy/copied.svelte.js";
|
|
34
34
|
import type { InboxDraftEdits, InboxItem, InboxVerdict } from "./inbox-types.js";
|
|
35
|
+
import { safeHref } from "../../lib/safe-href.js";
|
|
35
36
|
|
|
36
37
|
interface Props {
|
|
37
38
|
item: InboxItem;
|
|
@@ -261,9 +262,9 @@
|
|
|
261
262
|
{/if}
|
|
262
263
|
<span class={FACT}>
|
|
263
264
|
<!-- The linked record is a chip inside the line, a link when the project has a page. -->
|
|
264
|
-
{#if item.project.href}
|
|
265
|
+
{#if safeHref(item.project.href)}
|
|
265
266
|
<a
|
|
266
|
-
href={item.project.href}
|
|
267
|
+
href={safeHref(item.project.href)}
|
|
267
268
|
class={workLooks.chipLink}
|
|
268
269
|
aria-label="Project: {item.project.name}"
|
|
269
270
|
>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { Brain } from "phosphor-svelte";
|
|
3
3
|
import * as Trace from "../../shadcn/trace/index.js";
|
|
4
4
|
import type { InboxTrace } from "./inbox-types.js";
|
|
5
|
+
import { safeHref } from "../../lib/safe-href.js";
|
|
5
6
|
|
|
6
7
|
interface Props {
|
|
7
8
|
trace: InboxTrace;
|
|
@@ -30,12 +31,13 @@
|
|
|
30
31
|
</Trace.CollapsibleTrigger>
|
|
31
32
|
<Trace.CollapsibleContent class="mt-3 mb-0 ml-0 border-l-2 pr-0 pl-3">
|
|
32
33
|
<div class="flex flex-col gap-3">
|
|
33
|
-
{#if label || trace.source.href}
|
|
34
|
+
{#if label || safeHref(trace.source.href)}
|
|
34
35
|
<p class="text-foreground">
|
|
35
36
|
{label}
|
|
36
|
-
{#if trace.source.href}
|
|
37
|
-
<a
|
|
38
|
-
|
|
37
|
+
{#if safeHref(trace.source.href)}
|
|
38
|
+
<a
|
|
39
|
+
href={safeHref(trace.source.href)}
|
|
40
|
+
class="text-foreground underline underline-offset-2">Open source</a
|
|
39
41
|
>
|
|
40
42
|
{/if}
|
|
41
43
|
</p>
|
|
@@ -73,6 +73,8 @@
|
|
|
73
73
|
/** Width of the task column in px. It never takes more than 40% of a narrow screen. */
|
|
74
74
|
labelWidth?: number;
|
|
75
75
|
labelHeading?: string;
|
|
76
|
+
/** What a screen reader calls the list of bars. */
|
|
77
|
+
timelineLabel?: string;
|
|
76
78
|
locale?: string;
|
|
77
79
|
/** Drops the card chrome (border, radius, fill) so the chart sits flat on the page. */
|
|
78
80
|
flat?: boolean;
|
|
@@ -105,6 +107,7 @@
|
|
|
105
107
|
rowHeight = 40,
|
|
106
108
|
labelWidth = 240,
|
|
107
109
|
labelHeading = "Task",
|
|
110
|
+
timelineLabel = "Timeline",
|
|
108
111
|
locale = "en-US",
|
|
109
112
|
flat = false,
|
|
110
113
|
class: className,
|
|
@@ -227,7 +230,7 @@
|
|
|
227
230
|
draggingId={drag?.id ?? null}
|
|
228
231
|
/>
|
|
229
232
|
|
|
230
|
-
<div role="list" aria-label=
|
|
233
|
+
<div role="list" aria-label={timelineLabel}>
|
|
231
234
|
{#each rows as row, index (row.kind === "group" ? `g:${row.group.id}` : row.id)}
|
|
232
235
|
{#if row.kind === "group"}
|
|
233
236
|
<GanttGroupRow {row} {index} {layout} staggerCap={STAGGER_CAP} />
|
|
@@ -50,6 +50,8 @@ interface Props<T> {
|
|
|
50
50
|
/** Width of the task column in px. It never takes more than 40% of a narrow screen. */
|
|
51
51
|
labelWidth?: number;
|
|
52
52
|
labelHeading?: string;
|
|
53
|
+
/** What a screen reader calls the list of bars. */
|
|
54
|
+
timelineLabel?: string;
|
|
53
55
|
locale?: string;
|
|
54
56
|
/** Drops the card chrome (border, radius, fill) so the chart sits flat on the page. */
|
|
55
57
|
flat?: boolean;
|