Skip to content

Commit 3e19cd4

Browse files
Beta 2 (#28)
* fix(design): ui-link word break * dev(e2e): add new_tab to menu items * fix(e2e): production pnpm version and hex doc * fix(git): remove integration_test/deps * fix(deps): Revert to Gettext non optional * core(deps): up version * core(hooks): Allow lazy hooks and partial imports * add(doc): Guide for Tableau * dev(e2e): fix hero layout * core(deps): mix docs in docs * dev(docs): update doc * dev(integration_test): Remove mysql and mssql test * new(component): File Upload and File Upload Live * dev(floating-panel): fix styling and demo * dev(form): improve native input and switch erros * dev(e2e): Fix Controller Form and home * a11y(file-upload): no nested triggers
1 parent 2b0710b commit 3e19cd4

1,269 files changed

Lines changed: 7750 additions & 559718 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ corex-*.tar
4141
/installer/tmp/
4242
/installer/cover/
4343

44-
/integration_test/_build
45-
/integration_test/deps
44+
/integration_test/_build/
45+
/integration_test/deps/
4646
.dexter.db*
4747

4848
/.claude/

assets/components/file-upload.ts

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
import { connect, machine, type Props, type Api, type ItemType } from "@zag-js/file-upload";
2+
import { VanillaMachine } from "@zag-js/vanilla";
3+
import { Component } from "../lib/core";
4+
5+
const ACCEPTED: ItemType = "accepted";
6+
7+
function toChar(code: number): string {
8+
return String.fromCharCode(code + (code > 25 ? 39 : 97));
9+
}
10+
11+
function toName(code: number): string {
12+
let name = "";
13+
let x: number;
14+
for (x = Math.abs(code); x > 52; x = (x / 52) | 0) name = toChar(x % 52) + name;
15+
return toChar(x % 52) + name;
16+
}
17+
18+
function toPhash(h: number, x: string): number {
19+
let i = x.length;
20+
while (i) h = (h * 33) ^ x.charCodeAt(--i);
21+
return h;
22+
}
23+
24+
function zagFileId(value: string): string {
25+
return toName(toPhash(5381, value) >>> 0);
26+
}
27+
28+
function fileKeyFor(file: File): string {
29+
return zagFileId(`${file.name}-${file.size}`);
30+
}
31+
32+
export class FileUpload extends Component<Props, Api> {
33+
private previewCleanup = new Map<HTMLElement, VoidFunction>();
34+
private sentinelSnapshot = "";
35+
36+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
37+
initMachine(props: Props): VanillaMachine<any> {
38+
return new VanillaMachine(machine, props);
39+
}
40+
41+
initApi(): Api {
42+
return this.zagConnect(connect);
43+
}
44+
45+
cleanupPreviews(): void {
46+
for (const cleanup of this.previewCleanup.values()) cleanup();
47+
this.previewCleanup.clear();
48+
}
49+
50+
render(): void {
51+
const rootEl =
52+
this.el.querySelector<HTMLElement>('[data-scope="file-upload"][data-part="root"]') ?? this.el;
53+
this.spreadProps(rootEl, this.api.getRootProps());
54+
55+
const labelEl = this.el.querySelector<HTMLElement>(
56+
'[data-scope="file-upload"][data-part="label"]'
57+
);
58+
if (labelEl) this.spreadProps(labelEl, this.api.getLabelProps());
59+
60+
const dropzoneEl = this.el.querySelector<HTMLElement>(
61+
'[data-scope="file-upload"][data-part="dropzone"]'
62+
);
63+
if (dropzoneEl) this.spreadProps(dropzoneEl, this.api.getDropzoneProps());
64+
65+
const triggerEl = this.el.querySelector<HTMLElement>(
66+
'[data-scope="file-upload"][data-part="trigger"]'
67+
);
68+
if (triggerEl) this.spreadProps(triggerEl, this.api.getTriggerProps());
69+
70+
const hiddenInputEl =
71+
this.el.querySelector<HTMLElement>('[data-scope="file-upload"][data-part="hidden-input"]') ??
72+
rootEl.querySelector<HTMLElement>('input[type="file"]');
73+
if (hiddenInputEl) this.spreadProps(hiddenInputEl, this.api.getHiddenInputProps());
74+
75+
const acceptedGroup = this.el.querySelector<HTMLElement>(
76+
'ul[data-scope="file-upload"][data-part="item-group"][data-file-type="accepted"]'
77+
);
78+
if (acceptedGroup) {
79+
this.spreadProps(acceptedGroup, this.api.getItemGroupProps({ type: ACCEPTED }));
80+
this.syncAcceptedItems(acceptedGroup);
81+
}
82+
83+
const itemEls = this.el.querySelectorAll<HTMLElement>(
84+
'[data-scope="file-upload"][data-part="item"]'
85+
);
86+
for (const itemEl of Array.from(itemEls)) {
87+
const file = this.getAcceptedFileForElement(itemEl);
88+
if (!file) continue;
89+
90+
this.spreadProps(itemEl, this.api.getItemProps({ file, type: ACCEPTED }));
91+
92+
const itemNameEl = itemEl.querySelector<HTMLElement>(
93+
'[data-scope="file-upload"][data-part="item-name"]'
94+
);
95+
if (itemNameEl)
96+
this.spreadProps(itemNameEl, this.api.getItemNameProps({ file, type: ACCEPTED }));
97+
98+
const itemPreviewEl = itemEl.querySelector<HTMLElement>(
99+
'[data-scope="file-upload"][data-part="item-preview"]'
100+
);
101+
if (itemPreviewEl)
102+
this.spreadProps(itemPreviewEl, this.api.getItemPreviewProps({ file, type: ACCEPTED }));
103+
104+
const itemPreviewImageEl = itemEl.querySelector<HTMLElement>(
105+
'[data-scope="file-upload"][data-part="item-preview-image"]'
106+
);
107+
if (itemPreviewImageEl && file.type.startsWith("image/")) {
108+
const fk = fileKeyFor(file);
109+
const needsInit =
110+
itemPreviewImageEl.dataset.corexPreviewKey !== fk ||
111+
!itemPreviewImageEl.getAttribute("src");
112+
if (needsInit) {
113+
const prev = this.previewCleanup.get(itemPreviewImageEl);
114+
prev?.();
115+
itemPreviewImageEl.removeAttribute("src");
116+
const cleanup = this.api.createFileUrl(file, (url) => {
117+
this.spreadProps(
118+
itemPreviewImageEl,
119+
this.api.getItemPreviewImageProps({
120+
file,
121+
type: ACCEPTED,
122+
url,
123+
})
124+
);
125+
});
126+
this.previewCleanup.set(itemPreviewImageEl, cleanup);
127+
itemPreviewImageEl.dataset.corexPreviewKey = fk;
128+
} else {
129+
const url = itemPreviewImageEl.getAttribute("src") ?? "";
130+
if (url) {
131+
this.spreadProps(
132+
itemPreviewImageEl,
133+
this.api.getItemPreviewImageProps({
134+
file,
135+
type: ACCEPTED,
136+
url,
137+
})
138+
);
139+
}
140+
}
141+
}
142+
143+
const itemSizeTextEl = itemEl.querySelector<HTMLElement>(
144+
'[data-scope="file-upload"][data-part="item-size-text"]'
145+
);
146+
if (itemSizeTextEl) {
147+
this.spreadProps(itemSizeTextEl, this.api.getItemSizeTextProps({ file, type: ACCEPTED }));
148+
itemSizeTextEl.textContent = this.api.getFileSize(file);
149+
}
150+
151+
const itemDeleteTriggerEl = itemEl.querySelector<HTMLElement>(
152+
'[data-scope="file-upload"][data-part="item-delete-trigger"]'
153+
);
154+
if (itemDeleteTriggerEl) {
155+
this.spreadProps(
156+
itemDeleteTriggerEl,
157+
this.api.getItemDeleteTriggerProps({ file, type: ACCEPTED })
158+
);
159+
}
160+
}
161+
162+
this.touchSentinel();
163+
}
164+
165+
private touchSentinel(): void {
166+
const sentinel = this.el.querySelector<HTMLInputElement>('[data-part="hidden-input-sentinel"]');
167+
if (!sentinel) return;
168+
const snap = this.api.acceptedFiles.map((f) => fileKeyFor(f)).join(",");
169+
if (snap === this.sentinelSnapshot) return;
170+
this.sentinelSnapshot = snap;
171+
sentinel.dispatchEvent(new Event("input", { bubbles: true }));
172+
}
173+
174+
private syncAcceptedItems(ul: HTMLElement): void {
175+
this.syncItemList(ul, this.api.acceptedFiles);
176+
}
177+
178+
private syncItemList(ul: HTMLElement, files: File[]): void {
179+
const desiredKeys = files.map((f) => fileKeyFor(f));
180+
const desiredSet = new Set(desiredKeys);
181+
182+
const byKey = new Map<string, HTMLLIElement>();
183+
ul.querySelectorAll<HTMLLIElement>("li[data-corex-file-item]").forEach((li) => {
184+
const k = li.dataset.fileKey;
185+
if (k) byKey.set(k, li);
186+
});
187+
188+
for (const k of [...byKey.keys()]) {
189+
if (!desiredSet.has(k)) {
190+
const li = byKey.get(k);
191+
if (!li) continue;
192+
li.querySelectorAll<HTMLElement>('img[data-part="item-preview-image"]').forEach((img) => {
193+
const c = this.previewCleanup.get(img);
194+
if (c) {
195+
c();
196+
this.previewCleanup.delete(img);
197+
}
198+
});
199+
li.remove();
200+
byKey.delete(k);
201+
}
202+
}
203+
204+
for (const file of files) {
205+
const k = fileKeyFor(file);
206+
let li = byKey.get(k);
207+
if (!li) {
208+
li = this.buildItemLi(file, k);
209+
byKey.set(k, li);
210+
}
211+
ul.appendChild(li);
212+
}
213+
}
214+
215+
private buildItemLi(file: File, key: string): HTMLLIElement {
216+
const doc = this.doc;
217+
const li = doc.createElement("li");
218+
li.setAttribute("data-scope", "file-upload");
219+
li.setAttribute("data-part", "item");
220+
li.setAttribute("data-corex-file-item", "");
221+
li.dataset.fileKey = key;
222+
li.dataset.fileType = ACCEPTED;
223+
224+
const lead = doc.createElement("div");
225+
lead.setAttribute("data-scope", "file-upload");
226+
lead.setAttribute("data-part", "item-lead");
227+
if (file.type.startsWith("image/")) {
228+
const prev = doc.createElement("div");
229+
prev.setAttribute("data-scope", "file-upload");
230+
prev.setAttribute("data-part", "item-preview");
231+
const img = doc.createElement("img");
232+
img.setAttribute("data-scope", "file-upload");
233+
img.setAttribute("data-part", "item-preview-image");
234+
prev.appendChild(img);
235+
lead.appendChild(prev);
236+
}
237+
li.appendChild(lead);
238+
239+
const nameEl = doc.createElement("div");
240+
nameEl.setAttribute("data-scope", "file-upload");
241+
nameEl.setAttribute("data-part", "item-name");
242+
nameEl.textContent = file.name;
243+
li.appendChild(nameEl);
244+
245+
const sizeEl = doc.createElement("div");
246+
sizeEl.setAttribute("data-scope", "file-upload");
247+
sizeEl.setAttribute("data-part", "item-size-text");
248+
li.appendChild(sizeEl);
249+
250+
const del = doc.createElement("button");
251+
del.setAttribute("data-scope", "file-upload");
252+
del.setAttribute("data-part", "item-delete-trigger");
253+
del.type = "button";
254+
this.fillDeleteTriggerContent(del);
255+
li.appendChild(del);
256+
257+
return li;
258+
}
259+
260+
private fillDeleteTriggerContent(btn: HTMLElement): void {
261+
const tpl = this.el.querySelector<HTMLTemplateElement>(
262+
"[data-file-upload-item-close-template]"
263+
);
264+
if (!tpl?.content) return;
265+
btn.replaceChildren();
266+
const frag = tpl.content.cloneNode(true);
267+
for (const node of Array.from(frag.childNodes)) {
268+
if (node.nodeType === 1) {
269+
btn.appendChild(node);
270+
}
271+
}
272+
}
273+
274+
private getAcceptedFileForElement(el: HTMLElement): File | undefined {
275+
const k = el.dataset.fileKey;
276+
if (!k) return undefined;
277+
return this.api.acceptedFiles.find((f) => fileKeyFor(f) === k);
278+
}
279+
}

assets/hooks/corex.ts

Lines changed: 3 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import type { Hook } from "phoenix_live_view";
2-
31
export type {
42
AccordionChangedDetail,
53
TreeViewExpandedChangedDetail,
@@ -24,33 +22,9 @@ export {
2422
findDialogContent,
2523
} from "../lib/custom-animation";
2624

27-
type HookModule = Record<string, Hook<object, HTMLElement> | undefined>;
25+
import { createLazyHook } from "./lazy-hook";
2826

29-
function createLazyHook(importFn: () => Promise<HookModule>, exportName: string): Hook {
30-
return {
31-
async mounted() {
32-
const mod = await importFn();
33-
const real = mod[exportName];
34-
(this as { _realHook?: Hook<object, HTMLElement> })._realHook = real;
35-
if (real?.mounted) return real.mounted.call(this);
36-
},
37-
updated() {
38-
(this as { _realHook?: Hook })._realHook?.updated?.call(this);
39-
},
40-
destroyed() {
41-
(this as { _realHook?: Hook })._realHook?.destroyed?.call(this);
42-
},
43-
disconnected() {
44-
(this as { _realHook?: Hook })._realHook?.disconnected?.call(this);
45-
},
46-
reconnected() {
47-
(this as { _realHook?: Hook })._realHook?.reconnected?.call(this);
48-
},
49-
beforeUpdate() {
50-
(this as { _realHook?: Hook })._realHook?.beforeUpdate?.call(this);
51-
},
52-
};
53-
}
27+
export type { HookModule } from "./lazy-hook";
5428

5529
export const Hooks = {
5630
Accordion: createLazyHook(() => import("corex/accordion"), "Accordion"),
@@ -66,6 +40,7 @@ export const Hooks = {
6640
DatePicker: createLazyHook(() => import("corex/date-picker"), "DatePicker"),
6741
Dialog: createLazyHook(() => import("corex/dialog"), "Dialog"),
6842
Editable: createLazyHook(() => import("corex/editable"), "Editable"),
43+
FileUpload: createLazyHook(() => import("corex/file-upload"), "FileUpload"),
6944
FloatingPanel: createLazyHook(() => import("corex/floating-panel"), "FloatingPanel"),
7045
Listbox: createLazyHook(() => import("corex/listbox"), "Listbox"),
7146
Marquee: createLazyHook(() => import("corex/marquee"), "Marquee"),
@@ -85,12 +60,4 @@ export const Hooks = {
8560
TreeView: createLazyHook(() => import("corex/tree-view"), "TreeView"),
8661
};
8762

88-
export function hooks<T extends keyof typeof Hooks>(
89-
componentNames: readonly T[]
90-
): Pick<typeof Hooks, T> {
91-
return Object.fromEntries(
92-
componentNames.filter((name): name is T => name in Hooks).map((name) => [name, Hooks[name]])
93-
) as Pick<typeof Hooks, T>;
94-
}
95-
9663
export default Hooks;

0 commit comments

Comments
 (0)