kmaterialize 2.3.3-1.0.29 → 2.3.3-1.0.30
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/components/breadcrumb/_breadcrumb.scss +3 -1
- package/components/dropdown/dropdown.ts +6 -3
- package/components/extensions/chart-print.test.mjs +31 -0
- package/components/extensions/chart-print.ts +56 -7
- package/components/extensions/org-chart.scss +6 -0
- package/components/extensions/org-chart.ts +94 -14
- package/components/extensions/orgChartAvatarSpec.js +22 -0
- package/components/extensions/orgChartDragOptionsSpec.js +27 -0
- package/components/extensions/orgChartPlacementSpec.js +24 -0
- package/components/extensions/orgChartReadonlySpec.js +14 -0
- package/components/search/autocomplete.ts +31 -0
- package/components/search/autocompleteChipsSpec.js +20 -0
- package/components/textfield/_input-fields.scss +65 -0
- package/components/textfield/forms.ts +2 -0
- package/components/textfield/input-copy.ts +52 -0
- package/components/textfield/inputCopySpec.js +39 -0
- package/components/toolbar/_toolbar.scss +83 -0
- package/dist/css/materialize.colors.min.css +1 -1
- package/dist/css/materialize.css +236 -2
- package/dist/css/materialize.min.css +2 -2
- package/dist/js/materialize.cjs.js +230 -22
- package/dist/js/materialize.d.ts +34 -5
- package/dist/js/materialize.js +230 -22
- package/dist/js/materialize.min.js +3 -3
- package/dist/js/materialize.mjs +230 -23
- package/package.json +1 -1
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
vertical-align: middle;
|
|
19
19
|
align-items: center;
|
|
20
20
|
|
|
21
|
-
i
|
|
21
|
+
i { font-size: 24px; }
|
|
22
|
+
|
|
23
|
+
// Preserve other icon fonts' weights (Font Awesome solid requires 900).
|
|
22
24
|
i.material-icons, i.material-symbols-outlined,
|
|
23
25
|
i.material-symbols-rounded, i.material-symbols-sharp,
|
|
24
26
|
&:before {
|
|
@@ -244,11 +244,14 @@ export class Dropdown extends Component<DropdownOptions> implements Openable {
|
|
|
244
244
|
};
|
|
245
245
|
|
|
246
246
|
_handleDocumentClick = (e: MouseEvent) => {
|
|
247
|
-
|
|
248
|
-
|
|
247
|
+
// The original event path survives menu rerenders during option selection.
|
|
248
|
+
const path = e.composedPath();
|
|
249
|
+
const insideMenu = path.includes(this.dropdownEl);
|
|
250
|
+
const insideTrigger = path.includes(this.el);
|
|
251
|
+
if (this.options.closeOnClick && insideMenu && !this.isTouchMoving) {
|
|
249
252
|
// isTouchMoving to check if scrolling on mobile.
|
|
250
253
|
this.close();
|
|
251
|
-
} else if (!
|
|
254
|
+
} else if (!insideMenu && !insideTrigger) {
|
|
252
255
|
// Do this one frame later so that if the element clicked also triggers _handleClick
|
|
253
256
|
// For example, if a label for a select was clicked, that we don't close/open the dropdown
|
|
254
257
|
setTimeout(() => {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { expect, test } from 'vitest';
|
|
2
|
+
import { chartPrintLayout } from './chart-print';
|
|
3
|
+
|
|
4
|
+
test('preserves A3 landscape defaults and avoids enlarging small charts', () => {
|
|
5
|
+
expect(chartPrintLayout(100, 100)).toMatchObject({format:'A3',orientation:'landscape',scale:1});
|
|
6
|
+
});
|
|
7
|
+
test('fits A4 landscape and portrait within 10mm margins', () => {
|
|
8
|
+
const landscape = chartPrintLayout(2000, 1000, {format:'A4',orientation:'landscape'});
|
|
9
|
+
const portrait = chartPrintLayout(2000, 1000, {format:'A4',orientation:'portrait'});
|
|
10
|
+
expect(landscape.scale).toBeCloseTo(277*96/25.4/2000);
|
|
11
|
+
expect(portrait.scale).toBeCloseTo(190*96/25.4/2000);
|
|
12
|
+
});
|
|
13
|
+
test('scales tall charts by height and supports Letter', () => {
|
|
14
|
+
expect(chartPrintLayout(100,2000,{format:'letter',orientation:'portrait'}).scale).toBeCloseTo(259.4*96/25.4/2000);
|
|
15
|
+
});
|
|
16
|
+
test('rejects invalid options', () => {
|
|
17
|
+
expect(()=>chartPrintLayout(100,100,{format:'invalid'})).toThrow(/format/);
|
|
18
|
+
expect(()=>chartPrintLayout(100,100,{orientation:'sideways'})).toThrow(/orientation/);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('centers small and scaled charts within the printable area', () => {
|
|
22
|
+
for (const orientation of ['portrait', 'landscape']) {
|
|
23
|
+
for (const [width,height] of [[100,100],[1800,3000]]) {
|
|
24
|
+
const layout=chartPrintLayout(width,height,{format:'A4',orientation});
|
|
25
|
+
expect(layout.left).toBeGreaterThanOrEqual(0);
|
|
26
|
+
expect(layout.top).toBeGreaterThanOrEqual(0);
|
|
27
|
+
expect(layout.left*2+width*layout.scale).toBeCloseTo(layout.printableWidth);
|
|
28
|
+
expect(layout.top*2+height*layout.scale).toBeCloseTo(layout.printableHeight);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
});
|
|
@@ -1,12 +1,41 @@
|
|
|
1
1
|
export interface ChartPrintOptions {
|
|
2
2
|
/** PDF-only theme. Does not change the application theme. Defaults to light. */
|
|
3
3
|
theme?: 'light' | 'dark';
|
|
4
|
+
/** Paper format. Defaults to A3. */
|
|
5
|
+
format?: 'A0' | 'A1' | 'A2' | 'A3' | 'A4' | 'A5' | 'A6' | 'letter' | 'legal' | 'tabloid';
|
|
6
|
+
/** Page orientation. Defaults to landscape. */
|
|
7
|
+
orientation?: 'portrait' | 'landscape';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const paperSizes = {
|
|
11
|
+
A0: [841, 1189], A1: [594, 841], A2: [420, 594], A3: [297, 420],
|
|
12
|
+
A4: [210, 297], A5: [148, 210], A6: [105, 148],
|
|
13
|
+
letter: [215.9, 279.4], legal: [215.9, 355.6], tabloid: [279.4, 431.8]
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
export function chartPrintLayout(width: number, height: number, options: ChartPrintOptions = {}) {
|
|
17
|
+
const format = options.format ?? 'A3';
|
|
18
|
+
const orientation = options.orientation ?? 'landscape';
|
|
19
|
+
if (!Object.prototype.hasOwnProperty.call(paperSizes, format)) throw new TypeError('Invalid PDF paper format.');
|
|
20
|
+
if (orientation !== 'portrait' && orientation !== 'landscape') throw new TypeError('Invalid PDF orientation.');
|
|
21
|
+
const [short, long] = paperSizes[format];
|
|
22
|
+
const pageWidth = orientation === 'landscape' ? long : short;
|
|
23
|
+
const pageHeight = orientation === 'landscape' ? short : long;
|
|
24
|
+
const printableWidth = (pageWidth - 20) * 96 / 25.4;
|
|
25
|
+
const printableHeight = (pageHeight - 20) * 96 / 25.4;
|
|
26
|
+
const scale = Math.min(1, printableWidth / Math.max(1, width), printableHeight / Math.max(1, height));
|
|
27
|
+
return { format, orientation, scale, printableWidth, printableHeight,
|
|
28
|
+
left: (printableWidth - width * scale) / 2,
|
|
29
|
+
top: (printableHeight - height * scale) / 2 };
|
|
30
|
+
|
|
4
31
|
}
|
|
5
32
|
|
|
6
33
|
/** Print a full-size snapshot, without changing the live chart or its zoom. */
|
|
7
34
|
export async function printChart(stage: HTMLElement, title: string, options: ChartPrintOptions = {}): Promise<void> {
|
|
8
35
|
const theme = options.theme ?? 'light';
|
|
9
36
|
if (theme !== 'light' && theme !== 'dark') throw new TypeError('Invalid PDF theme.');
|
|
37
|
+
const width = stage.offsetWidth, height = stage.offsetHeight;
|
|
38
|
+
const { format, orientation } = chartPrintLayout(width, height, options);
|
|
10
39
|
// Open synchronously from the button click so popup blockers allow the preview.
|
|
11
40
|
const preview = window.open('', '_blank');
|
|
12
41
|
if (!preview) throw new Error('Allow popups to open the PDF print preview.');
|
|
@@ -43,14 +72,11 @@ export async function printChart(stage: HTMLElement, title: string, options: Cha
|
|
|
43
72
|
doc.head.append(clone);
|
|
44
73
|
return ready;
|
|
45
74
|
});
|
|
46
|
-
const width = stage.offsetWidth, height = stage.offsetHeight;
|
|
47
|
-
// A3 landscape, 10 mm margins. Shrink large charts to one page.
|
|
48
|
-
const scale = Math.min(1, (400 * 96 / 25.4) / width, (277 * 96 / 25.4) / height);
|
|
49
75
|
const style = doc.createElement('style');
|
|
50
|
-
style.textContent = `@page { size:
|
|
76
|
+
style.textContent = `@page { size: ${format} ${orientation}; margin: 10mm; } html, body { margin: 0; padding: 0; } body { background: ${theme === 'light' ? 'white' : 'var(--md-sys-color-surface)'}; color: var(--md-sys-color-on-surface); } * { print-color-adjust: exact; -webkit-print-color-adjust: exact; }`;
|
|
51
77
|
doc.head.append(style);
|
|
52
78
|
snapshot.style.setProperty('zoom', '1');
|
|
53
|
-
snapshot.style.transform =
|
|
79
|
+
snapshot.style.transform = 'none';
|
|
54
80
|
snapshot.style.transformOrigin = 'top left';
|
|
55
81
|
snapshot.style.position = 'absolute';
|
|
56
82
|
snapshot.style.left = '0'; snapshot.style.top = '0';
|
|
@@ -59,10 +85,33 @@ export async function printChart(stage: HTMLElement, title: string, options: Cha
|
|
|
59
85
|
handle.style.opacity = '0';
|
|
60
86
|
});
|
|
61
87
|
const page = doc.createElement('div');
|
|
62
|
-
page.style.cssText = `position:relative;width:${width
|
|
88
|
+
page.style.cssText = `position:relative;width:${width}px;height:${height}px;overflow:hidden`;
|
|
63
89
|
page.append(snapshot);
|
|
64
90
|
doc.body.replaceChildren(page);
|
|
65
91
|
await Promise.all(resources);
|
|
66
92
|
await doc.fonts.ready;
|
|
67
|
-
if (!preview.closed) {
|
|
93
|
+
if (!preview.closed) {
|
|
94
|
+
// Center the actual cards and connectors, excluding unused canvas space.
|
|
95
|
+
const boxes = Array.from(snapshot.querySelectorAll<HTMLElement>('.org-chart-team')).map(panel => ({
|
|
96
|
+
x: panel.offsetLeft, y: panel.offsetTop, width: panel.offsetWidth, height: panel.offsetHeight
|
|
97
|
+
}));
|
|
98
|
+
const links = snapshot.querySelector<SVGSVGElement>('.org-chart-links');
|
|
99
|
+
if (links?.childElementCount) {
|
|
100
|
+
const box = links.getBBox();
|
|
101
|
+
if (box.width > 0 || box.height > 0) boxes.push(box);
|
|
102
|
+
}
|
|
103
|
+
const left = boxes.length ? Math.min(...boxes.map(box => box.x)) - 8 : 0;
|
|
104
|
+
const top = boxes.length ? Math.min(...boxes.map(box => box.y)) - 8 : 0;
|
|
105
|
+
const contentWidth = boxes.length ? Math.max(...boxes.map(box => box.x + box.width)) - left + 8 : width;
|
|
106
|
+
const contentHeight = boxes.length ? Math.max(...boxes.map(box => box.y + box.height)) - top + 8 : height;
|
|
107
|
+
const layout = chartPrintLayout(contentWidth, contentHeight, options);
|
|
108
|
+
// Round down very slightly to avoid a blank second page from print rounding.
|
|
109
|
+
page.style.width = `${Math.floor(layout.printableWidth * 100) / 100}px`;
|
|
110
|
+
page.style.height = `${Math.floor(layout.printableHeight * 100) / 100}px`;
|
|
111
|
+
snapshot.style.transform = `scale(${layout.scale})`;
|
|
112
|
+
snapshot.style.left = `${layout.left - left * layout.scale}px`;
|
|
113
|
+
snapshot.style.top = `${layout.top - top * layout.scale}px`;
|
|
114
|
+
preview.focus();
|
|
115
|
+
preview.print();
|
|
116
|
+
}
|
|
68
117
|
}
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
max-height: 680px;
|
|
6
6
|
border: 1px solid var(--md-sys-color-outline-variant);
|
|
7
7
|
border-radius: 24px;
|
|
8
|
+
// Clip native scrollbars to the same rounded boundary as the chart.
|
|
9
|
+
clip-path: inset(0 round 24px);
|
|
8
10
|
background-color: var(--md-sys-color-surface);
|
|
9
11
|
color: var(--md-sys-color-on-surface);
|
|
10
12
|
background-image: radial-gradient(var(--md-sys-color-outline-variant) 1px, transparent 1px);
|
|
@@ -49,6 +51,7 @@
|
|
|
49
51
|
}
|
|
50
52
|
.org-chart-team {
|
|
51
53
|
position: absolute;
|
|
54
|
+
z-index: 2;
|
|
52
55
|
width: 248px;
|
|
53
56
|
padding: 8px;
|
|
54
57
|
border: 1px solid var(--md-sys-color-outline-variant);
|
|
@@ -97,6 +100,9 @@
|
|
|
97
100
|
&:last-child { margin-bottom: 0; }
|
|
98
101
|
}
|
|
99
102
|
.org-chart-avatar {
|
|
103
|
+
overflow: hidden;
|
|
104
|
+
min-width: 0;
|
|
105
|
+
> img { display: block; width: 100%; height: 100%; min-width: 0; object-fit: cover; }
|
|
100
106
|
display: grid;
|
|
101
107
|
place-items: center;
|
|
102
108
|
flex: 0 0 38px;
|
|
@@ -12,13 +12,29 @@ export interface OrgChartAppearance {
|
|
|
12
12
|
accent?: string;
|
|
13
13
|
accentPosition?: "top" | "right" | "bottom" | "left";
|
|
14
14
|
}
|
|
15
|
-
export interface OrgChartTeam extends OrgChartAppearance { id: string; name: string; x
|
|
16
|
-
export interface OrgChartPerson extends OrgChartAppearance {
|
|
15
|
+
export interface OrgChartTeam extends OrgChartAppearance { id: string; name: string; x?: number; y?: number }
|
|
16
|
+
export interface OrgChartPerson extends OrgChartAppearance {
|
|
17
|
+
id: string;
|
|
18
|
+
teamId: string;
|
|
19
|
+
name: string;
|
|
20
|
+
role?: string;
|
|
21
|
+
/** Custom avatar text; defaults to initials derived from name. */
|
|
22
|
+
avatarText?: string;
|
|
23
|
+
/** Image URL. A failed image falls back to avatarText or initials. */
|
|
24
|
+
avatarImage?: string;
|
|
25
|
+
/** How the image fills the avatar. Defaults to cover. */
|
|
26
|
+
avatarFit?: "cover" | "contain" | "fill" | "none" | "scale-down";
|
|
27
|
+
}
|
|
17
28
|
export interface OrgChartLink { from: string; to: string; fromType?: "person" | "team"; toType?: "person" | "team"; label?: string }
|
|
18
29
|
export interface OrgChartData { teams: OrgChartTeam[]; people: OrgChartPerson[]; links: OrgChartLink[] }
|
|
19
30
|
export interface OrgChartOptions {
|
|
20
31
|
data: OrgChartData;
|
|
32
|
+
/** Shared movement default. Individual movement options take precedence. */
|
|
21
33
|
draggable?: boolean;
|
|
34
|
+
/** Allow team movement. Defaults to draggable, then true. */
|
|
35
|
+
draggableTeams?: boolean;
|
|
36
|
+
/** Allow person reordering and movement between teams. Defaults to draggable, then true. */
|
|
37
|
+
draggablePeople?: boolean;
|
|
22
38
|
/** Enable mouse/touch connection ports on cards and groups. Defaults to true. */
|
|
23
39
|
connectable?: boolean;
|
|
24
40
|
/** Allow inline link-label editing. Defaults to false. */
|
|
@@ -32,7 +48,12 @@ export interface OrgChartOptions {
|
|
|
32
48
|
}
|
|
33
49
|
|
|
34
50
|
const svgNS = "http://www.w3.org/2000/svg";
|
|
35
|
-
|
|
51
|
+
type PositionedOrgChartData = Omit<OrgChartData, 'teams'> & { teams: (OrgChartTeam & { x: number; y: number })[] };
|
|
52
|
+
const copy = (data: OrgChartData): PositionedOrgChartData => {
|
|
53
|
+
const result = JSON.parse(JSON.stringify(data));
|
|
54
|
+
result.teams.forEach((team: OrgChartTeam) => { team.x ??= 24; team.y ??= 24; });
|
|
55
|
+
return result;
|
|
56
|
+
};
|
|
36
57
|
const element = <K extends keyof HTMLElementTagNameMap>(tag: K, className: string, text?: string) => {
|
|
37
58
|
const el = document.createElement(tag);
|
|
38
59
|
el.className = className;
|
|
@@ -42,10 +63,13 @@ const element = <K extends keyof HTMLElementTagNameMap>(tag: K, className: strin
|
|
|
42
63
|
|
|
43
64
|
/** Materialize organization chart; no graph dependency. */
|
|
44
65
|
export class OrgChart {
|
|
66
|
+
private get canDragTeams(): boolean { return this.options.draggableTeams ?? this.options.draggable ?? true; }
|
|
67
|
+
private get canDragPeople(): boolean { return this.options.draggablePeople ?? this.options.draggable ?? true; }
|
|
45
68
|
private zoom = 1;
|
|
46
69
|
private labelEditor?: { index: number; input: HTMLInputElement };
|
|
47
70
|
private static instances = new WeakMap<HTMLElement, OrgChart>();
|
|
48
|
-
private data:
|
|
71
|
+
private data: PositionedOrgChartData;
|
|
72
|
+
private autoPositions = new Map<string, { x: boolean; y: boolean }>();
|
|
49
73
|
private stage = element("div", "org-chart-stage");
|
|
50
74
|
private svg = document.createElementNS(svgNS, "svg");
|
|
51
75
|
private summary = element("div", "org-chart-summary");
|
|
@@ -71,6 +95,7 @@ export class OrgChart {
|
|
|
71
95
|
this.validate(options.data);
|
|
72
96
|
this.zoom = this.clampZoom(options.zoom ?? 1);
|
|
73
97
|
OrgChart.getInstance(el)?.destroy();
|
|
98
|
+
this.rememberMissingPositions(options.data);
|
|
74
99
|
this.data = copy(options.data);
|
|
75
100
|
this.originalNodes = Array.from(el.childNodes);
|
|
76
101
|
this.hadClass = el.classList.contains("org-chart");
|
|
@@ -144,6 +169,7 @@ export class OrgChart {
|
|
|
144
169
|
setData(data: OrgChartData): void {
|
|
145
170
|
this.validate(data);
|
|
146
171
|
this.endDrag();
|
|
172
|
+
this.rememberMissingPositions(data);
|
|
147
173
|
this.data = copy(data);
|
|
148
174
|
this.render();
|
|
149
175
|
this.options.onChange?.(this.getData());
|
|
@@ -166,9 +192,25 @@ export class OrgChart {
|
|
|
166
192
|
private validate(data: OrgChartData): void {
|
|
167
193
|
const teams = new Set<string>();
|
|
168
194
|
const people = new Set<string>();
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
195
|
+
const groups = new Map<string, { name: string; index: number }[]>();
|
|
196
|
+
data.teams.forEach((team, index) => {
|
|
197
|
+
const group = groups.get(team.id) || [];
|
|
198
|
+
group.push({ name: team.name, index });
|
|
199
|
+
groups.set(team.id, group);
|
|
200
|
+
});
|
|
201
|
+
const duplicates = [...groups].filter(([, group]) => group.length > 1);
|
|
202
|
+
if (duplicates.length) {
|
|
203
|
+
throw new Error('Duplicate team IDs: ' + duplicates.map(([id, group]) =>
|
|
204
|
+
`${JSON.stringify(id)}: ${group.map(team => `${JSON.stringify(team.name)} (teams[${team.index}])`).join(', ')}`
|
|
205
|
+
).join('; ') + '. Each team must have a unique ID.');
|
|
206
|
+
}
|
|
207
|
+
for (const [index, team] of data.teams.entries()) {
|
|
208
|
+
if (!team.id || typeof team.name !== 'string' || !team.name.trim())
|
|
209
|
+
throw new Error(`Invalid team at teams[${index}] (id=${JSON.stringify(team.id)}): a non-empty ID and name are required.`);
|
|
210
|
+
for (const axis of ['x', 'y'] as const) {
|
|
211
|
+
if (team[axis] != null && (!Number.isFinite(team[axis]) || team[axis] < 0))
|
|
212
|
+
throw new Error(`Invalid ${axis} for team ${JSON.stringify(team.name)} (id=${JSON.stringify(team.id)}, teams[${index}]): ${String(team[axis])}. Supply a finite, non-negative coordinate or omit it for automatic placement.`);
|
|
213
|
+
}
|
|
172
214
|
teams.add(team.id);
|
|
173
215
|
}
|
|
174
216
|
for (const person of data.people) {
|
|
@@ -214,10 +256,10 @@ export class OrgChart {
|
|
|
214
256
|
const header = element("button", "org-chart-team-handle");
|
|
215
257
|
header.type = "button";
|
|
216
258
|
header.dataset.team = team.id;
|
|
217
|
-
header.disabled = this.
|
|
259
|
+
header.disabled = !this.canDragTeams;
|
|
218
260
|
header.setAttribute("aria-label", `${team.name}, ${members.length} people. Drag or use arrow keys to move; Shift moves faster.`);
|
|
219
261
|
header.append(element("span", "org-chart-team-name", team.name), element("span", "org-chart-count", String(members.length)));
|
|
220
|
-
if (this.
|
|
262
|
+
if (this.canDragTeams) {
|
|
221
263
|
const icon = element("i", "material-icons org-chart-drag-icon", "drag_indicator");
|
|
222
264
|
icon.setAttribute("aria-hidden", "true");
|
|
223
265
|
header.prepend(icon);
|
|
@@ -229,8 +271,18 @@ export class OrgChart {
|
|
|
229
271
|
this.applyAppearance(card, person);
|
|
230
272
|
card.dataset.orgPerson = person.id;
|
|
231
273
|
const initials = person.name.trim().split(/\s+/).slice(0, 2).map(part => Array.from(part)[0]).join("");
|
|
232
|
-
const
|
|
274
|
+
const avatarText = person.avatarText ?? initials;
|
|
275
|
+
const avatar = element("span", "org-chart-avatar", avatarText);
|
|
233
276
|
avatar.setAttribute("aria-hidden", "true");
|
|
277
|
+
if (person.avatarImage) {
|
|
278
|
+
const image = document.createElement('img');
|
|
279
|
+
image.alt = '';
|
|
280
|
+
image.draggable = false;
|
|
281
|
+
image.style.objectFit = person.avatarFit ?? 'cover';
|
|
282
|
+
image.addEventListener('error', () => avatar.replaceChildren(document.createTextNode(avatarText)), { once: true });
|
|
283
|
+
image.src = person.avatarImage;
|
|
284
|
+
avatar.replaceChildren(image);
|
|
285
|
+
}
|
|
234
286
|
const details = element("div", "org-chart-person-details");
|
|
235
287
|
details.append(element("strong", "org-chart-person-name", person.name));
|
|
236
288
|
if (person.role) details.append(element("span", "org-chart-person-role", person.role));
|
|
@@ -254,9 +306,9 @@ export class OrgChart {
|
|
|
254
306
|
list.append(element("li", "", `${from.name} → ${to.name}: ${link.label || "Connected to"}`));
|
|
255
307
|
}
|
|
256
308
|
this.summary.append(element("h2", "", "Relationships"), list);
|
|
257
|
-
this.removeCardHandles = enableCardHandles(this.el, {
|
|
309
|
+
this.removeCardHandles = !this.canDragPeople ? undefined : enableCardHandles(this.el, {
|
|
258
310
|
cardSelector: '.org-chart-person', dropSelector: '.org-chart-team',
|
|
259
|
-
enabled: () => this.
|
|
311
|
+
enabled: () => this.canDragPeople,
|
|
260
312
|
onMove: (card, _from, to) => {
|
|
261
313
|
const person = this.data.people.find(person => person.id === card.dataset.orgPerson)!;
|
|
262
314
|
person.teamId = to.dataset.orgTeam!;
|
|
@@ -286,7 +338,34 @@ export class OrgChart {
|
|
|
286
338
|
return port;
|
|
287
339
|
}
|
|
288
340
|
|
|
341
|
+
private rememberMissingPositions(data: OrgChartData): void {
|
|
342
|
+
this.autoPositions.clear();
|
|
343
|
+
for (const team of data.teams) {
|
|
344
|
+
if (team.x == null || team.y == null)
|
|
345
|
+
this.autoPositions.set(team.id, { x: team.x == null, y: team.y == null });
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private placeAutomaticTeams(): void {
|
|
350
|
+
const placed = this.data.teams.filter(team => !this.autoPositions.has(team.id));
|
|
351
|
+
for (const team of this.data.teams) {
|
|
352
|
+
const missing = this.autoPositions.get(team.id);
|
|
353
|
+
if (!missing) continue;
|
|
354
|
+
// Place beyond occupied bounds; measured dimensions include all team members.
|
|
355
|
+
// Keep any authored coordinate, including zero.
|
|
356
|
+
if (missing.x) team.x = missing.y ? 24 : Math.max(24, ...placed.map(other =>
|
|
357
|
+
other.x + this.panels.get(other.id)!.offsetWidth + 48));
|
|
358
|
+
if (missing.y) team.y = Math.max(24, ...placed.map(other =>
|
|
359
|
+
other.y + this.panels.get(other.id)!.offsetHeight + 48));
|
|
360
|
+
const panel = this.panels.get(team.id)!;
|
|
361
|
+
panel.style.left = `${team.x}px`;
|
|
362
|
+
panel.style.top = `${team.y}px`;
|
|
363
|
+
placed.push(team);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
289
367
|
private draw(): void {
|
|
368
|
+
this.placeAutomaticTeams();
|
|
290
369
|
const vertical = this.options.orientation === "vertical";
|
|
291
370
|
const width = Math.max(vertical ? 0 : 900, ...this.data.teams.map(team => team.x + this.panels.get(team.id)!.offsetWidth + 80));
|
|
292
371
|
const height = Math.max(560, ...this.data.teams.map(team => team.y + this.panels.get(team.id)!.offsetHeight + 80));
|
|
@@ -448,7 +527,7 @@ export class OrgChart {
|
|
|
448
527
|
}
|
|
449
528
|
|
|
450
529
|
private startDrag = (event: PointerEvent): void => {
|
|
451
|
-
if (this.
|
|
530
|
+
if (!this.canDragTeams || event.button !== 0 || this.drag) return;
|
|
452
531
|
const handle = (event.target as HTMLElement).closest<HTMLElement>("[data-team]");
|
|
453
532
|
if (!handle) return;
|
|
454
533
|
const team = this.data.teams.find(team => team.id === handle.dataset.team)!;
|
|
@@ -472,7 +551,7 @@ export class OrgChart {
|
|
|
472
551
|
};
|
|
473
552
|
|
|
474
553
|
private moveWithKeyboard = (event: KeyboardEvent): void => {
|
|
475
|
-
if (this.
|
|
554
|
+
if (!this.canDragTeams) return;
|
|
476
555
|
const handle = (event.target as HTMLElement).closest<HTMLElement>("[data-team]");
|
|
477
556
|
const directions: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
|
|
478
557
|
if (!handle || !directions[event.key]) return;
|
|
@@ -485,6 +564,7 @@ export class OrgChart {
|
|
|
485
564
|
};
|
|
486
565
|
|
|
487
566
|
private move(id: string, x: number, y: number): void {
|
|
567
|
+
this.autoPositions.delete(id);
|
|
488
568
|
const team = this.data.teams.find(team => team.id === id)!;
|
|
489
569
|
team.x = Math.max(24, Math.round(x));
|
|
490
570
|
team.y = Math.max(24, Math.round(y));
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
describe('OrgChart custom avatars', () => {
|
|
2
|
+
it('renders custom text and a fitted image with a text fallback', () => {
|
|
3
|
+
const host = document.createElement('div'); document.body.append(host);
|
|
4
|
+
const chart = M.OrgChart.init(host, {connectable:false,data:{teams:[{id:'t',name:'Team'}],people:[
|
|
5
|
+
{id:'a',teamId:'t',name:'Alex Morgan',avatarText:'UX'},
|
|
6
|
+
{id:'b',teamId:'t',name:'Sam Rivera',avatarText:'SR!',avatarImage:'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="80" height="40"/%3E',avatarFit:'contain'},
|
|
7
|
+
{id:'c',teamId:'t',name:'Maya Patel'}
|
|
8
|
+
],links:[]}});
|
|
9
|
+
try {
|
|
10
|
+
const avatars=host.querySelectorAll('.org-chart-avatar');
|
|
11
|
+
expect(avatars[0].textContent).toBe('UX');
|
|
12
|
+
expect(avatars[2].textContent).toBe('MP');
|
|
13
|
+
const image=avatars[1].querySelector('img');
|
|
14
|
+
expect(getComputedStyle(image).objectFit).toBe('contain');
|
|
15
|
+
expect(image.alt).toBe('');
|
|
16
|
+
expect(image.draggable).toBeFalse();
|
|
17
|
+
image.dispatchEvent(new Event('error'));
|
|
18
|
+
expect(avatars[1].textContent).toBe('SR!');
|
|
19
|
+
expect(chart.getData().people[1].avatarFit).toBe('contain');
|
|
20
|
+
} finally {chart.destroy();host.remove();}
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
describe('OrgChart independent drag options', () => {
|
|
2
|
+
const cases = [
|
|
3
|
+
[{}, true, true],
|
|
4
|
+
[{draggable:false}, false, false],
|
|
5
|
+
[{draggableTeams:true,draggablePeople:false}, true, false],
|
|
6
|
+
[{draggableTeams:false,draggablePeople:true}, false, true],
|
|
7
|
+
[{draggable:false,draggableTeams:true}, true, false],
|
|
8
|
+
[{draggable:false,draggablePeople:true}, false, true]
|
|
9
|
+
];
|
|
10
|
+
cases.forEach(([options,teams,people]) => {
|
|
11
|
+
it('applies controls and team keyboard movement for '+JSON.stringify(options), () => {
|
|
12
|
+
const host=document.createElement('div');document.body.append(host);
|
|
13
|
+
const data={teams:[{id:'t',name:'Team',x:100,y:100}],people:[{id:'p',teamId:'t',name:'Person'}],links:[]};
|
|
14
|
+
const chart=M.OrgChart.init(host,{data,connectable:false,...options});
|
|
15
|
+
try {
|
|
16
|
+
const header=host.querySelector('.org-chart-team-handle');
|
|
17
|
+
expect(header.disabled).toBe(!teams);
|
|
18
|
+
expect(!!host.querySelector('.org-chart-drag-icon')).toBe(teams);
|
|
19
|
+
expect(!!host.querySelector('.card-drag-handle')).toBe(people);
|
|
20
|
+
header.dispatchEvent(new KeyboardEvent('keydown',{key:'ArrowRight',bubbles:true}));
|
|
21
|
+
expect(chart.getData().teams[0].x>100).toBe(teams);
|
|
22
|
+
chart.setData(data);
|
|
23
|
+
expect(!!host.querySelector('.card-drag-handle')).toBe(people);
|
|
24
|
+
} finally {chart.destroy();host.remove();}
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
describe('OrgChart automatic team placement', () => {
|
|
2
|
+
let host;
|
|
3
|
+
let chart;
|
|
4
|
+
beforeEach(() => { host = document.createElement('div'); host.style.width = '1000px'; document.body.append(host); });
|
|
5
|
+
afterEach(() => { chart?.destroy(); chart = null; host.remove(); });
|
|
6
|
+
it('lists all duplicate team IDs and team names', () => {
|
|
7
|
+
expect(() => M.OrgChart.init(host, {data:{teams:[{id:'a',name:'Design'},{id:'a',name:'Lighting'},{id:'b',name:'Production'},{id:'b',name:'Editorial'}],people:[],links:[]}})).toThrowError(/Duplicate team IDs:.*"a".*Design.*Lighting.*"b".*Production.*Editorial/);
|
|
8
|
+
});
|
|
9
|
+
it('places missing coordinates without overlaps and keeps authored coordinates', () => {
|
|
10
|
+
const data = {teams:[{id:'fixed',name:'Fixed',x:0,y:0},{id:'auto',name:'Automatic'},{id:'x',name:'Keep X',x:12},{id:'y',name:'Keep Y',y:0}],people:Array.from({length:8},(_,i)=>({id:String(i),name:'Person '+i,teamId:'fixed'})),links:[]};
|
|
11
|
+
chart = M.OrgChart.init(host,{data,connectable:false});
|
|
12
|
+
const teams = chart.getData().teams;
|
|
13
|
+
expect(teams[0].x).toBe(0); expect(teams[0].y).toBe(0);
|
|
14
|
+
expect(teams[2].x).toBe(12); expect(teams[3].y).toBe(0);
|
|
15
|
+
expect(data.teams[1].x).toBeUndefined();
|
|
16
|
+
const rects = [...host.querySelectorAll('.org-chart-team')].map(el=>el.getBoundingClientRect());
|
|
17
|
+
rects.forEach((a,i)=>rects.slice(i+1).forEach(b=>expect(a.right<=b.left||b.right<=a.left||a.bottom<=b.top||b.bottom<=a.top).toBeTrue()));
|
|
18
|
+
chart.setData({teams:[{id:'new',name:'New'}],people:[],links:[]});
|
|
19
|
+
expect(Number.isFinite(chart.getData().teams[0].x)).toBeTrue();
|
|
20
|
+
});
|
|
21
|
+
it('still rejects explicitly invalid coordinates with the team and axis', () => {
|
|
22
|
+
expect(()=>M.OrgChart.init(host,{data:{teams:[{id:'bad',name:'Bad Team',x:NaN}],people:[],links:[]}})).toThrowError(/Invalid x.*Bad Team.*bad/);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
describe('OrgChart read-only controls', () => {
|
|
2
|
+
it('omits drag and connection controls when their options are disabled', () => {
|
|
3
|
+
const host = document.createElement('div'); document.body.append(host);
|
|
4
|
+
const data = { teams: [{ id:'t', name:'Team' }], people: [{ id:'p', teamId:'t', name:'Person' }], links: [] };
|
|
5
|
+
const chart = M.OrgChart.init(host, { data, editable:false, connectable:false, draggable:false });
|
|
6
|
+
try {
|
|
7
|
+
expect(host.querySelector('.card-drag-handle')).toBeNull();
|
|
8
|
+
expect(host.querySelector('.org-chart-drag-icon')).toBeNull();
|
|
9
|
+
expect(host.querySelector('.org-chart-connect-port')).toBeNull();
|
|
10
|
+
chart.setData(data);
|
|
11
|
+
expect(host.querySelector('.card-drag-handle')).toBeNull();
|
|
12
|
+
} finally { chart.destroy(); host.remove(); }
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -270,6 +270,7 @@ export class Autocomplete extends Component<AutocompleteOptions> {
|
|
|
270
270
|
}
|
|
271
271
|
|
|
272
272
|
_removeDropdown() {
|
|
273
|
+
this.el.parentElement.querySelector('.autocomplete-selected')?.remove();
|
|
273
274
|
this.container.ariaExpanded = 'false';
|
|
274
275
|
this.container.parentNode.removeChild(this.container);
|
|
275
276
|
}
|
|
@@ -485,6 +486,36 @@ export class Autocomplete extends Component<AutocompleteOptions> {
|
|
|
485
486
|
}
|
|
486
487
|
|
|
487
488
|
_updateSelectedInfo() {
|
|
489
|
+
if (this.options.isMultiSelect) {
|
|
490
|
+
let selected = this.el.parentElement.querySelector<HTMLElement>('.autocomplete-selected');
|
|
491
|
+
if (!selected) {
|
|
492
|
+
selected = document.createElement('div');
|
|
493
|
+
selected.className = 'autocomplete-selected';
|
|
494
|
+
selected.setAttribute('aria-label', 'Selected items');
|
|
495
|
+
this.el.parentElement.append(selected);
|
|
496
|
+
}
|
|
497
|
+
selected.replaceChildren();
|
|
498
|
+
for (const entry of this.selectedValues) {
|
|
499
|
+
const chip = document.createElement('span');
|
|
500
|
+
chip.className = 'autocomplete-selected-chip';
|
|
501
|
+
const text = document.createElement('span');
|
|
502
|
+
text.textContent = String(entry.text ?? entry.id);
|
|
503
|
+
const remove = document.createElement('button');
|
|
504
|
+
remove.type = 'button';
|
|
505
|
+
remove.setAttribute('aria-label', `Remove ${text.textContent}`);
|
|
506
|
+
remove.textContent = '×';
|
|
507
|
+
remove.addEventListener('click', () => {
|
|
508
|
+
this.selectedValues = this.selectedValues.filter(item => item.id !== entry.id);
|
|
509
|
+
this._updateSelectedInfo();
|
|
510
|
+
if (this.dropdown.isOpen) this._renderDropdown();
|
|
511
|
+
this._triggerChanged();
|
|
512
|
+
this.el.focus({ preventScroll: true });
|
|
513
|
+
});
|
|
514
|
+
chip.append(text, remove);
|
|
515
|
+
selected.append(chip);
|
|
516
|
+
}
|
|
517
|
+
selected.hidden = !this.selectedValues.length;
|
|
518
|
+
}
|
|
488
519
|
const statusElement = this.el.parentElement.querySelector('.status-info');
|
|
489
520
|
if (statusElement) {
|
|
490
521
|
if (this.options.isMultiSelect)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
describe('Multi autocomplete selected chips', () => {
|
|
2
|
+
it('keeps the menu open after selection and lets a chip remove its value', async () => {
|
|
3
|
+
const field = document.createElement('div');
|
|
4
|
+
field.className = 'input-field';
|
|
5
|
+
field.innerHTML = '<input placeholder=" "><label>Search</label>';
|
|
6
|
+
document.body.append(field);
|
|
7
|
+
const input = field.querySelector('input');
|
|
8
|
+
const instance = M.Autocomplete.init(input, { isMultiSelect: true, minLength: 0, data: [{id:'a',text:'Alpha'}], dropdownOptions: {inDuration:0,outDuration:0} });
|
|
9
|
+
try {
|
|
10
|
+
instance.open();
|
|
11
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
12
|
+
instance.container.querySelector('li').click();
|
|
13
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
14
|
+
expect(instance.dropdown.isOpen).toBeTrue();
|
|
15
|
+
expect(field.querySelector('.autocomplete-selected-chip').textContent).toContain('Alpha');
|
|
16
|
+
field.querySelector('.autocomplete-selected-chip button').click();
|
|
17
|
+
expect(instance.selectedValues.length).toBe(0);
|
|
18
|
+
} finally { instance.destroy(); field.remove(); }
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -422,3 +422,68 @@ textarea {
|
|
|
422
422
|
> :is(input, textarea).invalid ~ .input-outline,
|
|
423
423
|
&.error > .input-outline { border-color: var(--md-sys-color-error); }
|
|
424
424
|
}
|
|
425
|
+
|
|
426
|
+
// Explicitly authored copy buttons; no button is generated for ordinary inputs.
|
|
427
|
+
.input-field {
|
|
428
|
+
> button.suffix.input-copy-button {
|
|
429
|
+
display: inline-flex;
|
|
430
|
+
align-items: center;
|
|
431
|
+
justify-content: center;
|
|
432
|
+
width: 40px;
|
|
433
|
+
height: 40px;
|
|
434
|
+
top: 8px;
|
|
435
|
+
right: 8px;
|
|
436
|
+
padding: 0;
|
|
437
|
+
border: 0;
|
|
438
|
+
border-radius: 50%;
|
|
439
|
+
background: transparent;
|
|
440
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
441
|
+
cursor: pointer;
|
|
442
|
+
z-index: 1;
|
|
443
|
+
> i { margin: 0; font-size: 24px; line-height: 1; }
|
|
444
|
+
&:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
|
|
445
|
+
&:focus-visible { outline: 2px solid var(--input-color); outline-offset: 2px; }
|
|
446
|
+
&:disabled { opacity: .38; cursor: default; }
|
|
447
|
+
}
|
|
448
|
+
&:has(> .input-copy-button) > input:not([type=checkbox]):not([type=radio]),
|
|
449
|
+
&:has(> .input-copy-button) > textarea { padding-right: 56px; }
|
|
450
|
+
}
|
|
451
|
+
.input-copy-status {
|
|
452
|
+
position: absolute;
|
|
453
|
+
width: 1px;
|
|
454
|
+
height: 1px;
|
|
455
|
+
overflow: hidden;
|
|
456
|
+
clip-path: inset(50%);
|
|
457
|
+
white-space: nowrap;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
.autocomplete-selected {
|
|
461
|
+
display: flex;
|
|
462
|
+
flex-wrap: wrap;
|
|
463
|
+
gap: 6px;
|
|
464
|
+
margin-top: 8px;
|
|
465
|
+
&[hidden] { display: none; }
|
|
466
|
+
.autocomplete-selected-chip {
|
|
467
|
+
display: inline-flex;
|
|
468
|
+
align-items: center;
|
|
469
|
+
gap: 6px;
|
|
470
|
+
padding: 4px 8px 4px 12px;
|
|
471
|
+
border-radius: 8px;
|
|
472
|
+
background: var(--md-sys-color-secondary-container);
|
|
473
|
+
color: var(--md-sys-color-on-secondary-container);
|
|
474
|
+
button {
|
|
475
|
+
display: inline-flex;
|
|
476
|
+
align-items: center;
|
|
477
|
+
justify-content: center;
|
|
478
|
+
min-width: 28px;
|
|
479
|
+
min-height: 28px;
|
|
480
|
+
border: 0;
|
|
481
|
+
border-radius: 50%;
|
|
482
|
+
background: transparent;
|
|
483
|
+
color: inherit;
|
|
484
|
+
cursor: pointer;
|
|
485
|
+
font-size: 20px;
|
|
486
|
+
&:focus-visible { outline: 2px solid currentColor; }
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { initInputCopyButtons } from './input-copy';
|
|
1
2
|
import { initOutlinedNotches } from './outlined-notch';
|
|
2
3
|
import { Utils } from '../../src/utils';
|
|
3
4
|
|
|
@@ -112,6 +113,7 @@ export class Forms {
|
|
|
112
113
|
|
|
113
114
|
static Init() {
|
|
114
115
|
initOutlinedNotches();
|
|
116
|
+
initInputCopyButtons();
|
|
115
117
|
if (typeof document !== 'undefined')
|
|
116
118
|
document?.addEventListener('DOMContentLoaded', () => {
|
|
117
119
|
document.addEventListener('change', (e: KeyboardEvent) => {
|