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.
@@ -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
- const target = <HTMLElement>e.target;
248
- if (this.options.closeOnClick && target.closest('.dropdown-content') && !this.isTouchMoving) {
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 (!target.closest('.dropdown-content') && !target.closest('.dropdown-trigger')) {
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: A3 landscape; 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; }`;
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 = `scale(${scale})`;
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 * scale}px;height:${height * scale}px;overflow:hidden`;
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) { preview.focus(); preview.print(); }
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: number; y: number }
16
- export interface OrgChartPerson extends OrgChartAppearance { id: string; teamId: string; name: string; role?: string }
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
- const copy = (data: OrgChartData): OrgChartData => JSON.parse(JSON.stringify(data));
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: OrgChartData;
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
- for (const team of data.teams) {
170
- if (!team.id || teams.has(team.id) || !team.name.trim() || !Number.isFinite(team.x) || !Number.isFinite(team.y) || team.x < 0 || team.y < 0)
171
- throw new Error("Teams need unique IDs, a name, and finite, non-negative coordinates.");
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.options.draggable === false;
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.options.draggable !== false) {
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 avatar = element("span", "org-chart-avatar", initials);
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.options.draggable !== false,
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.options.draggable === false || event.button !== 0 || this.drag) return;
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.options.draggable === false) return;
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) => {