Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions assets/components/pagination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { connect, machine, type Api, type Props } from "@zag-js/pagination";
import type { IntlTranslations } from "@zag-js/pagination";
import { VanillaMachine } from "@zag-js/vanilla";
import { Component } from "../lib/core";
import { getString } from "../lib/util";
import { cloneTemplateChildren, getString } from "../lib/util";

export class Pagination extends Component<Props, Api> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -46,7 +46,6 @@ export class Pagination extends Component<Props, Api> {
const ellipsisTemplate = this.el.querySelector<HTMLElement>(
"[data-pagination-ellipsis-template]"
);
const ellipsisHtml = ellipsisTemplate?.innerHTML ?? "&#8230;";
const triggerType = getString(this.el, "type") ?? "button";
const pages = this.api.pages;

Expand Down Expand Up @@ -95,7 +94,9 @@ export class Pagination extends Component<Props, Api> {
let span = ellipsisEl;
if (!span) {
span = document.createElement("span");
span.innerHTML = ellipsisHtml;
if (!cloneTemplateChildren(ellipsisTemplate, span)) {
span.textContent = "\u2026";
}
li.appendChild(span);
}

Expand Down
39 changes: 22 additions & 17 deletions assets/components/toast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
} from "@zag-js/toast";
import { VanillaMachine } from "@zag-js/vanilla";
import { Component } from "../lib/core";
import { getDir } from "../lib/util";
import { cloneTemplateChildren, getDir } from "../lib/util";

export function actionClassTokens(action: unknown): string[] {
if (action == null || typeof action !== "object") return [];
Expand All @@ -22,6 +22,14 @@ export function actionClassTokens(action: unknown): string[] {
return cn.trim().split(/\s+/).filter(Boolean);
}

function actionLabelHtml(action: unknown): boolean {
return (
action != null &&
typeof action === "object" &&
(action as { labelHtml?: unknown }).labelHtml === true
);
}

export const toastGroups = new Map<string, ToastGroup>();
export const toastStores = new Map<string, Store>();

Expand Down Expand Up @@ -125,18 +133,9 @@ export class ToastItem<T = unknown> extends Component<ToastItemProps<T>, Api> {
"[data-close-icon-template]"
) as HTMLElement;

const loadingIcon = loadingIconTemplate?.innerHTML;
const closeIcon = closeIconTemplate?.innerHTML;

// inject close icon
if (closeIcon) {
if (this.parts.close.innerHTML !== closeIcon) {
this.parts.close.innerHTML = closeIcon;
}
} else {
// fallback
if (!this.parts.close.innerHTML) {
this.parts.close.innerHTML = "×";
if (!cloneTemplateChildren(closeIconTemplate, this.parts.close)) {
if (this.parts.close.childNodes.length === 0 && !this.parts.close.textContent) {
this.parts.close.textContent = "×";
}
}

Expand Down Expand Up @@ -168,7 +167,12 @@ export class ToastItem<T = unknown> extends Component<ToastItemProps<T>, Api> {
this.parts.action.hidden = false;
this.spreadProps(this.parts.action, this.api.getActionTriggerProps());
const label = this.latestProps.action?.label ?? "";
if (this.parts.action.textContent !== label) {
const labelHtml = actionLabelHtml(this.latestProps.action);
if (labelHtml) {
if (this.parts.action.innerHTML !== label) {
this.parts.action.innerHTML = label;
}
} else if (this.parts.action.textContent !== label) {
this.parts.action.textContent = label;
}
const extraClasses = actionClassTokens(this.latestProps.action);
Expand All @@ -178,6 +182,9 @@ export class ToastItem<T = unknown> extends Component<ToastItemProps<T>, Api> {
if (this.parts.action.textContent) {
this.parts.action.textContent = "";
}
if (this.parts.action.innerHTML) {
this.parts.action.innerHTML = "";
}
}

const duration = this.duration;
Expand All @@ -194,9 +201,7 @@ export class ToastItem<T = unknown> extends Component<ToastItemProps<T>, Api> {

if (this.showLoading) {
this.parts.loadingSpinner.style.display = "flex";
if (loadingIcon && this.parts.loadingSpinner.innerHTML !== loadingIcon) {
this.parts.loadingSpinner.innerHTML = loadingIcon;
}
cloneTemplateChildren(loadingIconTemplate, this.parts.loadingSpinner);
} else {
this.parts.loadingSpinner.style.display = "none";
}
Expand Down
9 changes: 7 additions & 2 deletions assets/hooks/toast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ type ToastActionSpec = {
label: string;
encoded: string;
className?: string;
labelHtml?: boolean;
};

type ToastHookRuntime = {
Expand Down Expand Up @@ -48,20 +49,24 @@ export function parseActionSpec(raw: unknown): ToastActionSpec | null {
if (typeof className === "string" && className.trim()) {
spec.className = className.trim();
}
if (o.labelHtml === true) {
spec.labelHtml = true;
}
return spec;
}

function buildZagAction(
spec: ToastActionSpec,
rt: ToastHookRuntime
): ActionOptions & { className?: string } {
const action: ActionOptions & { className?: string } = {
): ActionOptions & { className?: string; labelHtml?: boolean } {
const action: ActionOptions & { className?: string; labelHtml?: boolean } = {
label: spec.label,
onClick: () => {
rt.execJs(spec.encoded);
},
};
if (spec.className) action.className = spec.className;
if (spec.labelHtml) action.labelHtml = true;
return action;
}

Expand Down
19 changes: 19 additions & 0 deletions assets/lib/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,25 @@ export function templatesContentRoot(
return host as HTMLElement;
}

export function cloneTemplateChildren(
template: HTMLElement | null | undefined,
target: HTMLElement
): boolean {
if (!template || template.childNodes.length === 0) return false;

const sourceId = template.id;
if (sourceId && target.dataset.templateSource === sourceId && target.childNodes.length > 0) {
return true;
}

target.replaceChildren(...Array.from(template.childNodes, (node) => node.cloneNode(true)));

if (sourceId) target.dataset.templateSource = sourceId;
else delete target.dataset.templateSource;

return true;
}

/**
* Generate a random ID if none is provided
* @param element - Optional HTML element to get an existing id
Expand Down
52 changes: 52 additions & 0 deletions assets/test/component/pagination.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
applyPhoenixLinkAttrs,
applyPhoenixLinkAttrsToNavigableParts,
buildGetPageUrl,
Pagination,
parsePaginationTranslations,
uniquePaginationTranslations,
} from "../../components/pagination";
Expand Down Expand Up @@ -65,3 +66,54 @@ describe("applyPhoenixLinkAttrsToNavigableParts", () => {
expect(item?.getAttribute("data-phx-link")).toBe("patch");
});
});

describe("Pagination ellipsis template", () => {
it("clones ellipsis slot content instead of using innerHTML", () => {
const root = document.createElement("div");
root.id = "pager";
root.dataset.type = "button";

const nav = document.createElement("nav");
const ul = document.createElement("ul");
const prev = document.createElement("li");
prev.setAttribute("data-pagination-part", "prev");
const next = document.createElement("li");
next.setAttribute("data-pagination-part", "next");
ul.append(prev, next);
nav.appendChild(ul);
root.appendChild(nav);

const paginationRoot = document.createElement("div");
paginationRoot.dataset.scope = "pagination";
paginationRoot.dataset.part = "root";
root.appendChild(paginationRoot);

const ellipsisTemplate = document.createElement("div");
ellipsisTemplate.id = "pager-ellipsis";
ellipsisTemplate.setAttribute("data-pagination-ellipsis-template", "");
ellipsisTemplate.hidden = true;
const marker = document.createElement("span");
marker.setAttribute("data-testid", "ellipsis-icon");
marker.textContent = "more";
ellipsisTemplate.appendChild(marker);
root.appendChild(ellipsisTemplate);

const pagination = new Pagination(root, {
id: "pager",
count: 200,
page: 10,
pageSize: 10,
siblingCount: 1,
boundaryCount: 1,
});
pagination.init();

const ellipsis = root.querySelector<HTMLElement>(
'[data-scope="pagination"][data-part="ellipsis"]'
);
expect(ellipsis?.querySelector("[data-testid='ellipsis-icon']")).toBeTruthy();
expect(ellipsisTemplate.querySelector("[data-testid='ellipsis-icon']")).toBeTruthy();

pagination.destroy();
});
});
73 changes: 73 additions & 0 deletions assets/test/component/toast.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, it, afterEach } from "vitest";
import type { ActionOptions } from "@zag-js/toast";
import {
actionClassTokens,
createToast,
Expand Down Expand Up @@ -88,4 +89,76 @@ describe("toast group API", () => {

document.body.removeChild(container);
});

it("renders html action labels when labelHtml is true", () => {
const container = document.createElement("div");
container.id = groupId;
container.setAttribute("phx-hook", "Toast");
document.body.appendChild(container);

const { group, store } = createToastGroup(container, { id: groupId });
store.create({
id: "t-html-label",
title: "Title",
type: "info",
action: {
label: '<span data-testid="custom-label">Open</span>',
labelHtml: true,
onClick: () => {},
} as ActionOptions & { labelHtml?: boolean },
});
group.render();

const action = container.querySelector<HTMLElement>(
'[data-scope="toast"][data-part="action-trigger"]'
);
expect(action?.querySelector('[data-testid="custom-label"]')).toBeTruthy();

document.body.removeChild(container);
});

it("clones close and loading icons from templates", () => {
const container = document.createElement("div");
container.id = groupId;
container.setAttribute("phx-hook", "Toast");

const closeTemplate = document.createElement("div");
closeTemplate.id = `${groupId}-close-icon`;
closeTemplate.setAttribute("data-close-icon-template", "");
const closeSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
closeSvg.setAttribute("data-testid", "close-icon");
closeTemplate.appendChild(closeSvg);

const loadingTemplate = document.createElement("div");
loadingTemplate.id = `${groupId}-loading-icon`;
loadingTemplate.setAttribute("data-loading-icon-template", "");
const loadingSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
loadingSvg.setAttribute("data-testid", "loading-icon");
loadingTemplate.appendChild(loadingSvg);

container.append(closeTemplate, loadingTemplate);
document.body.appendChild(container);

const { group, store } = createToastGroup(container, { id: groupId });
store.create({
id: "t-icons",
title: "Title",
type: "info",
meta: { loading: true },
});
group.render();

const close = container.querySelector<HTMLElement>(
'[data-scope="toast"][data-part="close-trigger"]'
);
const loading = container.querySelector<HTMLElement>(
'[data-scope="toast"][data-part="loading-spinner"]'
);

expect(close?.querySelector("svg[data-testid='close-icon']")).toBeTruthy();
expect(loading?.querySelector("svg[data-testid='loading-icon']")).toBeTruthy();
expect(closeTemplate.querySelector("svg")).toBeTruthy();

document.body.removeChild(container);
});
});
10 changes: 10 additions & 0 deletions assets/test/hooks/toast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ describe("parseActionSpec", () => {
).toEqual({ label: "Go", encoded: "x", className: "button--sm" });
});

it("includes labelHtml when present", () => {
expect(
parseActionSpec({
label: "<span>Open</span>",
labelHtml: true,
effects: [{ kind: "exec_js", encoded: "x" }],
})
).toEqual({ label: "<span>Open</span>", encoded: "x", labelHtml: true });
});

it("returns null for invalid shape", () => {
expect(parseActionSpec(null)).toBeNull();
expect(parseActionSpec({ label: "" })).toBeNull();
Expand Down
22 changes: 22 additions & 0 deletions assets/test/lib/util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "vitest";
import { el } from "../helpers/dom";
import {
canPushEvent,
cloneTemplateChildren,
generateId,
getBoolean,
getBooleanValue,
Expand Down Expand Up @@ -123,6 +124,27 @@ describe("templatesContentRoot", () => {
});
});

describe("cloneTemplateChildren", () => {
it("clones template child nodes into the target", () => {
const template = document.createElement("div");
template.id = "tpl";
const icon = document.createElement("span");
icon.textContent = "icon";
template.appendChild(icon);

const target = document.createElement("button");
expect(cloneTemplateChildren(template, target)).toBe(true);
expect(target.querySelector("span")?.textContent).toBe("icon");
expect(template.querySelector("span")).toBeTruthy();
});

it("returns false when the template is empty", () => {
const target = document.createElement("button");
expect(cloneTemplateChildren(document.createElement("div"), target)).toBe(false);
expect(target.childNodes.length).toBe(0);
});
});

describe("generateId", () => {
it("reuses element id", () => {
const node = document.createElement("div");
Expand Down
2 changes: 1 addition & 1 deletion lib/components/toast.ex
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ defmodule Corex.Toast do

<!-- tabs-close -->

`create` opts: `duration`, `loading: true`, `id: "stable-id"`, `priority:` `1`–`8`, `action:` map with `label`, `js`, optional `class`.
`create` opts: `duration`, `loading: true`, `id: "stable-id"`, `priority:` `1`–`8`, `action:` map with `label` (string or `~H` / safe HTML), `js`, optional `class`.

## Style

Expand Down
Loading
Loading