PageSourceSearch

https://mobileinventory.net/assets/hello/demo.js?v=7fe8925b8d

js mobileinventory.net collected 2026-10-01 23:53:10 UTC 34,886 bytes, 914 lines download raw bytes

1/* Mobile Inventory homepage demo. Synthetic data only; no API, storage or external dependencies. */
2(function () {
3  'use strict';
4  const homepageRoot = new URL('../../', document.currentScript.src);
5  const root = document.getElementById('try_it');
6  if (!root || root.dataset.demoReady === 'true') return;
7  root.dataset.demoReady = 'true';
8  const $ = (selector) => root.querySelector(selector);
9  const $$ = (selector) => Array.from(root.querySelectorAll(selector));
10  const t = (key, fallback) => {
11    const value =
12      window.MIHello && typeof window.MIHello.t === 'function'
13        ? window.MIHello.t(key, fallback)
14        : fallback;
15    return typeof value === 'string' && value !== key ? value : fallback;
16  };
17  const interpolate = (key, fallback, values) =>
18    t(key, fallback).replace(/\{\{\s*(\w+)\s*\}\}/g, (match, name) =>
19      Object.prototype.hasOwnProperty.call(values, name) ? values[name] : match
20    );
21  const esc = (value) =>
22    String(value).replace(
23      /[&<>"']/g,
24      (character) =>
25        ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[character])
26    );
27  const number = (value) =>
28    new Intl.NumberFormat(document.documentElement.lang || 'en', {
29      maximumFractionDigits: 3,
30    }).format(value);
31  const data = [
32    {
33      id: 'box-cutter',
34      key: 'trans_Box cutter',
35      name: 'Box cutter',
36      barcode: '40093059279',
37      stock: [5, 2, 0, 0],
38      image: 'assets/hello/images/demo-box-cutter.webp',
39      thumbnail: 'assets/hello/images/demo-box-cutter-thumb.webp',
40    },
41    {
42      id: 'pencils',
43      key: 'trans_Pencils',
44      name: 'Pencils',
45      barcode: '95774033876',
46      stock: [20, 10, 3, 0],
47      thumbnail: 'assets/hello/images/demo-pencils-thumb.webp',
48    },
49    {
50      id: 'erasers',
51      key: 'trans_Erasers',
52      name: 'Erasers',
53      barcode: '83767928702',
54      stock: [15, 5, 3, 0],
55      thumbnail: 'assets/hello/images/demo-erasers-thumb.webp',
56    },
57    {
58      id: 'ruler',
59      key: 'trans_Ruler',
60      name: 'Ruler',
61      barcode: '52295514445',
62      stock: [10, 5, 2, 0],
63      thumbnail: 'assets/hello/images/demo-ruler-thumb.webp',
64    },
65    {
66      id: 'mouse',
67      key: 'trans_Mouse',
68      name: 'Mouse',
69      barcode: '05061954019',
70      stock: [30, 10, 8, 2],
71      image: 'assets/hello/images/demo-mouse.webp',
72      thumbnail: 'assets/hello/images/demo-mouse-thumb.webp',
73    },
74  ];
75  const locations = [
76    { id: 'a', key: 'trans_Warehouse A', name: 'Warehouse A' },
77    { id: 'b', key: 'trans_Warehouse B', name: 'Warehouse B' },
78    { id: 'office', key: 'trans_Office A', name: 'Office A' },
79    { id: 'client', key: 'trans_Client X', name: 'Client X' },
80  ];
81  // Thousandths keep repeated decimal additions and transfers exact.
82  let products = data.map((product) => ({
83    ...product,
84    units: product.stock.map((quantity) => quantity * 1000),
85  }));
86  const state = {
87    selected: null,
88    type: 'IN',
89    sort: 'name',
90    direction: 1,
91    query: '',
92    view: 'all',
93    hidden: new Set(),
94    recent: null,
95    message: null,
96    activeOption: -1,
97  };
98  let recentTimer;
99  const productName = (product) => t(product.key, product.name);
100  const locationName = (location) => t(location.key, location.name);
101  const imagePath = (product) => product.image || 'assets/hello/images/demo-' + product.id + '.jpg';
102  function imageMarkup(product, large) {
103    // Use compact 3x thumbnails for the 50px table and print images.
104    const source = !large && product.thumbnail ? product.thumbnail : imagePath(product);
105    return (
106      '<img src="' +
107      new URL(source, homepageRoot).href +
108      '" alt="' +
109      esc(productName(product)) +
110      '"' +
111      (large ? '' : ' width="50" height="50" loading="lazy"') +
112      '>'
113    );
114  }
115  const units = (product) =>
116    state.view === 'all'
117      ? product.units.reduce((sum, value) => sum + value, 0)
118      : product.units[locations.findIndex((location) => location.id === state.view)];
119  const norm = (value) =>
120    String(value)
121      .normalize('NFD')
122      .replace(/[\u0300-\u036f]/g, '')
123      .toLocaleLowerCase();
124  const visibleProducts = () =>
125    products
126      .filter((product) => state.view === 'all' || units(product) > 0)
127      .filter((product) =>
128        norm(productName(product) + ' ' + product.name + ' ' + product.barcode).includes(
129          norm(state.query)
130        )
131      )
132      .sort((a, b) => {
133        let result;
134        if (state.sort === 'stock') result = units(a) - units(b);
135        else if (state.sort === 'barcode') result = a.barcode.localeCompare(b.barcode);
136        else
137          result = productName(a).localeCompare(
138            productName(b),
139            document.documentElement.lang || 'en',
140            { sensitivity: 'base' }
141          );
142        return result * state.direction;
143      });
144  const headers = () => ({
145    name: t('trans_Product name', 'Product name'),
146    barcode: t('trans_barcode', 'Barcode'),
147    stock:
148      state.view === 'all'
149        ? t('trans_Total stock', 'Total stock')
150        : t('trans_stock', 'Stock') +
151          ': ' +
152          locationName(locations.find((location) => location.id === state.view)),
153    image: t('trans_Image', 'Image'),
154  });
155  const labelDefaults = new Map();
156  $$('[data-i18n]').forEach((node) => labelDefaults.set(node, node.textContent));
157  function refreshLabels() {
158    labelDefaults.forEach((fallback, node) => {
159      if (node.isConnected) node.textContent = t(node.dataset.i18n, fallback);
160    });
161    $('#mi-demo-product').placeholder = t('trans_select_product', 'Select product').replace(
162      /^\*\s*/,
163      ''
164    );
165    $('.mi-demo-picker-toggle').setAttribute(
166      'aria-label',
167      t('mi_demo_show_products', 'Show products')
168    );
169    $('.mi-demo-types').setAttribute(
170      'aria-label',
171      t('mi_demo_transaction_type', 'Transaction type')
172    );
173    $('.mi-demo-table-scroll').setAttribute('aria-label', t('mi_demo_products', 'Demo products'));
174    $('.mi-demo-lightbox-close').setAttribute('aria-label', t('trans_close', 'Close'));
175    if (state.selected)
176      $('#mi-demo-product').value = productName(
177        products.find((product) => product.id === state.selected)
178      );
179    updateType();
180    renderTable();
181    renderMessage();
182    if (!$('#mi-demo-products').hidden) renderOptions();
183    const openProduct = products.find(
184      (product) => product.id === $('#mi-demo-lightbox').dataset.product
185    );
186    if (openProduct) {
187      $('#mi-demo-image-title').textContent = productName(openProduct);
188      $('#mi-demo-large-visual').innerHTML = imageMarkup(openProduct, true);
189    }
190  }
191  function renderTable() {
192    const rows = visibleProducts();
193    $('#mi-demo-stock-title').textContent = headers().stock;
194    $('#mi-demo-table tbody').innerHTML = rows.length
195      ? rows
196          .map((product) => {
197            const recent = state.recent && state.recent.id === product.id;
198            return (
199              '<tr' +
200              (recent ? ' class="mi-demo-updated" data-change="' + state.recent.type + '"' : '') +
201              '>' +
202              '<td data-column="name">' +
203              esc(productName(product)) +
204              '</td>' +
205              '<td data-column="barcode">' +
206              product.barcode +
207              '</td>' +
208              '<td data-column="stock"><strong class="mi-demo-stock-value">' +
209              esc(number(units(product) / 1000)) +
210              '</strong>' +
211              (recent
212                ? '<span class="mi-demo-sr"> ' + esc(t('mi_demo_updated', 'Updated')) + '</span>'
213                : '') +
214              '</td>' +
215              '<td data-column="image"><button type="button" class="mi-demo-thumb" data-image="' +
216              product.id +
217              '" aria-label="' +
218              esc(
219                interpolate('mi_demo_view_image', 'View {{product}} image', {
220                  product: productName(product),
221                })
222              ) +
223              '">' +
224              imageMarkup(product, false) +
225              '</button></td></tr>'
226            );
227          })
228          .join('')
229      : '<tr><td class="mi-demo-empty" colspan="' +
230        (4 - state.hidden.size) +
231        '">' +
232        esc(t('table_no_records', 'No matching records found')) +
233        '</td></tr>';
234    $$('[data-column]').forEach((node) => {
235      node.hidden = state.hidden.has(node.dataset.column);
236    });
237    $$('th[data-column]').forEach((node) => {
238      const active = node.dataset.column === state.sort;
239      if (active)
240        node.setAttribute('aria-sort', state.direction === 1 ? 'ascending' : 'descending');
241      else node.removeAttribute('aria-sort');
242      const arrow = node.querySelector('.mi-demo-sort');
243      if (arrow) arrow.textContent = active ? (state.direction === 1 ? '↑' : '↓') : '↕';
244    });
245    $('#mi-demo-count').textContent = interpolate(
246      'mi_demo_result_count',
247      '{{shown}} of {{total}} products',
248      { shown: number(rows.length), total: number(products.length) }
249    );
250    renderLocationStock();
251  }
252  function renderLocationStock() {
253    const product = products.find((item) => item.id === state.selected);
254    const location = locations.find((item) => item.id === $('#mi-demo-location').value);
255    $('#mi-demo-location-stock').textContent =
256      product && location
257        ? interpolate('mi_demo_available', '{{location}}: {{quantity}} in stock', {
258            location: locationName(location),
259            quantity: number(product.units[locations.indexOf(location)] / 1000),
260          })
261        : '';
262    [$('#mi-demo-location'), $('#mi-demo-destination')].forEach((select) => {
263      locations.forEach((location, index) => {
264        select.options[index + 1].textContent =
265          locationName(location) +
266          (product ? ' (' + number(product.units[index] / 1000) + ')' : '');
267      });
268    });
269  }
270  function renderMessage() {
271    const node = $('#mi-demo-message');
272    node.className = 'mi-demo-message' + (state.message ? ' is-' + state.message.kind : '');
273    if (!state.message) {
274      node.textContent = '';
275      return;
276    }
277    const message = state.message;
278    let values = { ...message.values };
279    if (values.productId) {
280      values.product = productName(products.find((product) => product.id === values.productId));
281    }
282    if (values.locationId) {
283      values.location = locationName(
284        locations.find((location) => location.id === values.locationId)
285      );
286    }
287    if (values.toId) {
288      values.to = locationName(locations.find((location) => location.id === values.toId));
289    }
290    if (typeof values.amount === 'number') values.quantity = number(values.amount);
291    const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
292    icon.setAttribute('class', 'mi-demo-message-icon');
293    icon.setAttribute('viewBox', '0 0 24 24');
294    icon.setAttribute('fill', 'none');
295    icon.setAttribute('stroke', 'currentColor');
296    icon.setAttribute('stroke-width', '1.8');
297    icon.setAttribute('stroke-linecap', 'round');
298    icon.setAttribute('stroke-linejoin', 'round');
299    icon.setAttribute('aria-hidden', 'true');
300    icon.setAttribute('focusable', 'false');
301    const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
302    circle.setAttribute('cx', '12');
303    circle.setAttribute('cy', '12');
304    circle.setAttribute('r', '9');
305    const symbol = document.createElementNS('http://www.w3.org/2000/svg', 'path');
306    symbol.setAttribute('d', message.kind === 'success' ? 'm8 12 3 3 5-6' : 'M12 7v6m0 4h.01');
307    icon.append(circle, symbol);
308    const text = document.createElement('span');
309    text.textContent = interpolate(message.key, message.fallback, values);
310    node.replaceChildren(icon, text);
311  }
312  function message(kind, key, fallback, values, focus) {
313    state.message = { kind, key, fallback, values: values || {} };
314    renderMessage();
315    if (focus) focus.focus();
316  }
317  function updateType() {
318    $$('.mi-demo-type').forEach((button) => {
319      const active = button.dataset.type === state.type;
320      button.classList.toggle('is-active', active);
321      button.setAttribute('aria-pressed', String(active));
322    });
323    const isMove = state.type === 'MOVE';
324    $('#mi-demo-destination-field').hidden = !isMove;
325    $('#mi-demo-destination').required = isMove;
326    $('#mi-demo-location-label').textContent = t(
327      isMove ? 'trans_from_location' : 'trans_select_location',
328      isMove ? 'From location' : 'Select location'
329    );
330    $('#mi-demo-location').options[0].textContent = $('#mi-demo-location-label').textContent;
331    renderLocationStock();
332  }
333  function options() {
334    const value = state.selected ? '' : $('#mi-demo-product').value;
335    return products.filter((product) =>
336      norm(productName(product) + ' ' + product.name + ' ' + product.barcode).includes(norm(value))
337    );
338  }
339  function renderOptions() {
340    const rows = options();
341    const active = state.activeOption;
342    $('#mi-demo-products').innerHTML = rows.length
343      ? rows
344          .map(
345            (product, index) =>
346              '<li role="option" id="mi-demo-option-' +
347              index +
348              '" data-product="' +
349              product.id +
350              '" aria-selected="' +
351              (index === active) +
352              '">' +
353              esc(productName(product)) +
354              '<small>' +
355              product.barcode +
356              '</small></li>'
357          )
358          .join('')
359      : '<li role="option" aria-disabled="true">' +
360        esc(t('trans_product_not_found', 'Product not found')) +
361        '</li>';
362    if (active >= 0 && rows[active])
363      $('#mi-demo-product').setAttribute('aria-activedescendant', 'mi-demo-option-' + active);
364    else $('#mi-demo-product').removeAttribute('aria-activedescendant');
365  }
366  function showOptions() {
367    state.activeOption = -1;
368    renderOptions();
369    $('#mi-demo-products').hidden = false;
370    $('#mi-demo-product').setAttribute('aria-expanded', 'true');
371  }
372  function hideOptions() {
373    $('#mi-demo-products').hidden = true;
374    $('#mi-demo-product').setAttribute('aria-expanded', 'false');
375    $('#mi-demo-product').removeAttribute('aria-activedescendant');
376  }
377  function choose(id) {
378    const product = products.find((item) => item.id === id);
379    if (!product) return;
380    state.selected = id;
381    $('#mi-demo-product').value = productName(product);
382    hideOptions();
383    renderLocationStock();
384  }
385  $('#mi-demo-product').addEventListener('focus', showOptions);
386  $('#mi-demo-product').addEventListener('input', () => {
387    state.selected = null;
388    showOptions();
389    renderLocationStock();
390  });
391  $('.mi-demo-picker-toggle').addEventListener('click', () => {
392    if ($('#mi-demo-products').hidden) {
393      $('#mi-demo-product').focus();
394      showOptions();
395    } else hideOptions();
396  });
397  $('#mi-demo-products').addEventListener('mousedown', (event) => event.preventDefault());
398  $('#mi-demo-products').addEventListener('click', (event) => {
399    const option = event.target.closest('[data-product]');
400    if (option) choose(option.dataset.product);
401  });
402  $('#mi-demo-product').addEventListener('keydown', (event) => {
403    if (event.key === 'Escape') {
404      hideOptions();
405      event.preventDefault();
406      return;
407    }
408    if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
409      event.preventDefault();
410      if ($('#mi-demo-products').hidden) showOptions();
411      const rows = options();
412      state.activeOption = rows.length
413        ? state.activeOption === -1
414          ? event.key === 'ArrowDown'
415            ? 0
416            : rows.length - 1
417          : (state.activeOption + (event.key === 'ArrowDown' ? 1 : -1) + rows.length) % rows.length
418        : -1;
419      renderOptions();
420      const selected = $('#mi-demo-products [aria-selected=true]');
421      if (selected) selected.scrollIntoView({ block: 'nearest' });
422      return;
423    }
424    if (event.key === 'Enter' && !$('#mi-demo-products').hidden) {
425      event.preventDefault();
426      const rows = options();
427      const selected = rows[state.activeOption] || (rows.length === 1 ? rows[0] : null);
428      if (selected) choose(selected.id);
429    }
430    if (event.key === 'Tab') hideOptions();
431  });
432  document.addEventListener('click', (event) => {
433    if (!event.target.closest('.mi-demo-picker')) hideOptions();
434  });
435  $$('.mi-demo-type').forEach((button) =>
436    button.addEventListener('click', () => {
437      state.type = button.dataset.type;
438      state.message = null;
439      renderMessage();
440      updateType();
441    })
442  );
443  const quantityInput = $('#mi-demo-quantity');
444  const quantityButtons = $$('[data-quantity-step]');
445  function quantityUnits() {
446    const value = Number(quantityInput.value);
447    return quantityInput.value.trim() && Number.isFinite(value) ? Math.round(value * 1000) : 0;
448  }
449  function updateQuantityControls() {
450    const value = quantityUnits();
451    quantityButtons.forEach((button) => {
452      const next = value + Number(button.dataset.quantityStep) * 1000;
453      button.disabled = next < 1 || next > 1000000000;
454    });
455  }
456  function stepQuantity(delta) {
457    const next = quantityUnits() + delta * 1000;
458    if (next < 1 || next > 1000000000) return;
459    quantityInput.value = String(next / 1000);
460    quantityInput.dispatchEvent(new Event('input', { bubbles: true }));
461    quantityInput.dispatchEvent(new Event('change', { bubbles: true }));
462  }
463  quantityButtons.forEach((button) =>
464    button.addEventListener('click', () => stepQuantity(Number(button.dataset.quantityStep)))
465  );
466  quantityInput.addEventListener('focus', () => quantityInput.select());
467  quantityInput.addEventListener('input', updateQuantityControls);
468  quantityInput.addEventListener('keydown', (event) => {
469    if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
470      event.preventDefault();
471      stepQuantity(event.key === 'ArrowUp' ? 1 : -1);
472    }
473  });
474  updateQuantityControls();
475  $('#mi-demo-location').addEventListener('change', renderLocationStock);
476  $('#mi-demo-form').addEventListener('submit', (event) => {
477    event.preventDefault();
478    hideOptions();
479    const product = products.find((item) => item.id === state.selected);
480    const quantityInput = $('#mi-demo-quantity');
481    const raw = quantityInput.value.trim();
482    const quantity = Number(raw);
483    const scaled = Math.round(quantity * 1000);
484    if (!product)
485      return message(
486        'error',
487        'trans_msg_product_required',
488        'Please select a product.',
489        {},
490        $('#mi-demo-product')
491      );
492    if (
493      !raw ||
494      !Number.isFinite(quantity) ||
495      scaled < 1 ||
496      quantity > 1000000 ||
497      quantity !== scaled / 1000
498    )
499      return message(
500        'error',
501        'mi_demo_quantity_invalid',
502        'Enter a quantity above 0 and up to 1,000,000, with at most 3 decimal places.',
503        {},
504        quantityInput
505      );
506    const from = locations.findIndex((location) => location.id === $('#mi-demo-location').value);
507    const to = locations.findIndex((location) => location.id === $('#mi-demo-destination').value);
508    if (from < 0)
509      return message(
510        'error',
511        'mi_demo_location_required',
512        'Please select a location.',
513        {},
514        $('#mi-demo-location')
515      );
516    if (state.type === 'MOVE' && to < 0)
517      return message(
518        'error',
519        'mi_demo_destination_required',
520        'Please select a destination location.',
521        {},
522        $('#mi-demo-destination')
523      );
524    if (state.type === 'MOVE' && to === from)
525      return message(
526        'error',
527        'mi_demo_different_locations',
528        'Choose two different locations for a move.',
529        {},
530        $('#mi-demo-destination')
531      );
532    if (state.type !== 'IN' && product.units[from] < scaled)
533      return message(
534        'error',
535        'mi_demo_not_enough_stock',
536        'There is not enough stock at this location. Try a smaller quantity or another location.',
537        {},
538        quantityInput
539      );
540    if (
541      state.type === 'IN' &&
542      !Number.isSafeInteger(product.units.reduce((sum, value) => sum + value, 0) + scaled)
543    )
544      return message(
545        'error',
546        'mi_demo_stock_limit',
547        'The demo stock limit has been reached. Reset the demo to continue.',
548        {}
549      );
550    if (state.type === 'MOVE' && !Number.isSafeInteger(product.units[to] + scaled))
551      return message(
552        'error',
553        'mi_demo_stock_limit',
554        'The demo stock limit has been reached. Reset the demo to continue.',
555        {}
556      );
557    product.units[from] += state.type === 'IN' ? scaled : -scaled;
558    if (state.type === 'MOVE') product.units[to] += scaled;
559    const common = { productId: product.id, locationId: locations[from].id, amount: quantity };
560    if (state.type === 'IN')
561      message(
562        'success',
563        'mi_demo_added',
564        'Added {{quantity}} {{product}} to {{location}}.',
565        common
566      );
567    else if (state.type === 'OUT')
568      message(
569        'success',
570        'mi_demo_removed',
571        'Removed {{quantity}} {{product}} from {{location}}.',
572        common
573      );
574    else
575      message(
576        'success',
577        'mi_demo_moved',
578        'Moved {{quantity}} {{product}} from {{location}} to {{to}}
578. Total stock is unchanged.',
579        { ...common, toId: locations[to].id }
580      );
581    state.recent = { id: product.id, type: state.type };
582    clearTimeout(recentTimer);
583    renderTable();
584    recentTimer = setTimeout(() => {
585      state.recent = null;
586      $$('.mi-demo-updated').forEach((row) => {
587        row.classList.remove('mi-demo-updated');
588        row.removeAttribute('data-change');
589        const note = row.querySelector('.mi-demo-sr');
590        if (note) note.remove();
591      });
592    }, 3000);
593  });
594  $('#mi-demo-search').addEventListener('input', (event) => {
595    state.query = event.target.value;
596    renderTable();
597  });
598  $('#mi-demo-view').addEventListener('change', (event) => {
599    state.view = event.target.value;
600    renderTable();
601  });
602  $$('[data-sort]').forEach((button) =>
603    button.addEventListener('click', () => {
604      state.direction = state.sort === button.dataset.sort ? -state.direction : 1;
605      state.sort = button.dataset.sort;
606      renderTable();
607    })
608  );
609  $$('.mi-demo-columns input').forEach((input) =>
610    input.addEventListener('change', () => {
611      if (!input.checked && state.hidden.size === 3) {
612        input.checked = true;
613        return;
614      }
615      if (input.checked) state.hidden.delete(input.value);
616      else state.hidden.add(input.value);
617      renderTable();
618    })
619  );
620  $('#mi-demo-reset').addEventListener('click', () => {
621    clearTimeout(recentTimer);
622    products = data.map((product) => ({
623      ...product,
624      units: product.stock.map((quantity) => quantity * 1000),
625    }));
626    Object.assign(state, {
627      selected: null,
628      type: 'IN',
629      sort: 'name',
630      direction: 1,
631      query: '',
632      view: 'all',
633      recent: null,
634      activeOption: -1,
635    });
636    state.hidden.clear();
637    $('#mi-demo-form').reset();
638    updateQuantityControls();
639    $('#mi-demo-product').value = '';
640    $('#mi-demo-search').value = '';
641    $('#mi-demo-view').value = 'all';
642    $$('.mi-demo-columns input').forEach((input) => {
643      input.checked = true;
644    });
645    $('#mi-demo-column-picker').open = false;
646    hideOptions();
647    updateType();
648    renderTable();
649    message(
650      'success',
651      'mi_demo_reset_done',
652      'Demo reset. All sample quantities have been restored.'
653    );
654  });
655  $('#mi-demo-table').addEventListener('click', (event) => {
656    const button = event.target.closest('[data-image]');
657    if (!button) return;
658    const product = products.find((item) => item.id === button.dataset.image);
659    if (!product) return;
660    const dialog = $('#mi-demo-lightbox');
661    dialog.dataset.product = product.id;
662    $('#mi-demo-large-visual').innerHTML = imageMarkup(product, true);
663    $('#mi-demo-image-title').textContent = productName(product);
664    dialog.showModal();
665  });
666  $('.mi-demo-lightbox-close').addEventListener('click', () => $('#mi-demo-lightbox').close());
667  $('#mi-demo-lightbox').addEventListener('close', () => {
668    const product = products.find((item) => item.id === $('#mi-demo-lightbox').dataset.product);
669    const trigger = product && $('[data-image="' + product.id + '"]');
670    if (trigger) trigger.focus({ preventScroll: true });
671  });
672  $('#mi-demo-lightbox').addEventListener('click', (event) => {
673    if (event.target === $('#mi-demo-lightbox')) {
674      const bounds = event.target.getBoundingClientRect();
675      if (
676        event.clientX < bounds.left ||
677        event.clientX > bounds.right ||
678        event.clientY < bounds.top ||
679        event.clientY > bounds.bottom
680      )
681        event.target.close();
682    }
683  });
684  function exportRows() {
685    const columns = ['name', 'barcode', 'stock', 'image'].filter(
686      (column) => !state.hidden.has(column)
687    );
688    const titles = headers();
689    const rows = [columns.map((column) => titles[column])];
690    visibleProducts().forEach((product) =>
691      rows.push(
692        columns.map((column) =>
693          column === 'name'
694            ? productName(product)
695            : column === 'stock'
696            ? units(product) / 1000
697            : column === 'barcode'
698            ? product.barcode
699            : productName(product)
700        )
701      )
702    );
703    return rows;
704  }
705  function worksheet(rows) {
706    const letter = (index) => {
707      let text = '';
708      for (let value = index + 1; value > 0; value = Math.floor((value - 1) / 26))
709        text = String.fromCharCode(65 + ((value - 1) % 26)) + text;
710      return text;
711    };
712    return (
713      '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>' +
714      rows
715        .map(
716          (row, r) =>
717            '<row r="' +
718            (r + 1) +
719            '">' +
720            row
721              .map((value, c) =>
722                typeof value === 'number'
723                  ? '<c r="' + letter(c) + (r + 1) + '"><v>' + value + '</v></c>'
724                  : '<c r="' +
725                    letter(c) +
726                    (r + 1) +
727                    '" t="inlineStr"><is><t xml:space="preserve">' +
728                    esc(value) +
729                    '</t></is></c>'
730              )
731              .join('') +
732            '</row>'
733        )
734        .join('') +
735      '</sheetData></worksheet>'
736    );
737  }
738  // Standard uncompressed ZIP container. Each workbook entry receives its own CRC-32.
739  function zip(files) {
740    const encoder = new TextEncoder();
741    const parts = [];
742    const directory = [];
743    let offset = 0;
744    const crc = (bytes) => {
745      let value = 0xffffffff;
746      for (const byte of bytes) {
747        value ^= byte;
748        for (let bit = 0; bit < 8; bit++) value = (value >>> 1) ^ (value & 1 ? 0xedb88320 : 0);
749      }
750      return (value ^ 0xffffffff) >>> 0;
751    };
752    const header = (size) => new Uint8Array(size);
753    const set = (bytes, pos, value, size) => {
754      const view = new DataView(bytes.buffer);
755      if (size === 2) view.setUint16(pos, value, true);
756      else view.setUint32(pos, value, true);
757    };
758    for (const [name, text] of Object.entries(files)) {
759      const nameBytes = encoder.encode(name);
760      const bytes = encoder.encode(text);
761      const checksum = crc(bytes);
762      const local = header(30);
763      set(local, 0, 0x04034b50, 4);
764      set(local, 4, 20, 2);
765      set(local, 6, 0x800, 2);
766      set(local, 12, 33, 2);
767      set(local, 14, checksum, 4);
768      set(local, 18, bytes.length, 4);
769      set(local, 22, bytes.length, 4);
770      set(local, 26, nameBytes.length, 2);
771      parts.push(local, nameBytes, bytes);
772      const central = header(46);
773      set(central, 0, 0x02014b50, 4);
774      set(central, 4, 20, 2);
775      set(central, 6, 20, 2);
776      set(central, 8, 0x800, 2);
777      set(central, 14, 33, 2);
778      set(central, 16, checksum, 4);
779      set(central, 20, bytes.length, 4);
780      set(central, 24, bytes.length, 4);
781      set(central, 28, nameBytes.length, 2);
782      set(central, 42, offset, 4);
783      directory.push(central, nameBytes);
784      offset += local.length + nameBytes.length + bytes.length;
785    }
786    const length = directory.reduce((sum, item) => sum + item.length, 0);
787    const end = header(22);
788    set(end, 0, 0x06054b50, 4);
789    set(end, 8, Object.keys(files).length, 2);
790    set(end, 10, Object.keys(files).length, 2);
791    set(end, 12, length, 4);
792    set(end, 16, offset, 4);
793    return new Blob([...parts, ...directory, end], {
794      type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
795    });
796  }
797  function workbook() {
798    const stocks = [
799      [
800        t('trans_Product name', 'Product name'),
801        t('trans_barcode', 'Barcode'),
802        ...locations.map(locationName),
803        t('trans_Total stock', 'Total stock'),
804      ],
805      ...visibleProducts().map((product) => [
806        productName(product),
807        product.barcode,
808        ...product.units.map((quantity) => quantity / 1000),
809        product.units.reduce((sum, value) => sum + value, 0) / 1000,
810      ]),
811    ];
812    const files = {
813      '[Content_Types].xml':
814        '<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/worksheets/sheet2.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>',
815      '_rels/.rels':
816        '<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>',
817      'xl/workbook.xml':
818        '<?xml version="1.0" encoding="UTF-8"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Products" sheetId="1" r:id="rId1"/><sheet name="Stock by location" sheetId="2" r:id="rId2"/></sheets></workbook>',
819      'xl/_rels/workbook.xml.rels':
820        '<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet2.xml"/></Relationships>',
821      'xl/worksheets/sheet1.xml': worksheet(exportRows()),
822      'xl/worksheets/sheet2.xml': worksheet(stocks),
823    };
824    return zip(files);
825  }
826  $('#mi-demo-export').addEventListener('click', () => {
827    try {
828      const blob = workbook();
829      const url = URL.createObjectURL(blob);
830      const link = document.createElement('a');
831      link.href = url;
832      link.download = 'Mobile-Inventory-demo-' + new Date().toISOString().slice(0, 10) + '.xlsx';
833      link.style.display = 'none';
834      document.body.appendChild(link);
835      link.click();
836      link.remove();
837      setTimeout(() => URL.revokeObjectURL(url), 10000);
838    } catch (error) {
839      message('error', 'mi_demo_export_error', 'The file could not be created. Please try again.');
840    }
841  });
842  $('#mi-demo-print').addEventListener('click', () => {
843    const rows = exportRows();
844    const columns = ['name', 'barcode', 'stock', 'image'].filter(
845      (column) => !state.hidden.has(column)
846    );
847    const printProducts = visibleProducts();
848    const frame = document.createElement('iframe');
849    frame.className = 'mi-demo-print-frame';
850    frame.title = t('print', 'Print');
851    frame.style.cssText =
852      'position:fixed;width:1px;height:1px;inset:0;opacity:0;pointer-events:none;border:0';
853    frame.onload = async () => {
854      const printWindow = frame.contentWindow;
855      if (!printWindow) return;
856      printWindow.addEventListener('afterprint', () => frame.remove(), { once: true });
857      await Promise.allSettled([
858        ...Array.from(printWindow.document.images, (img) => img.decode()),
859        printWindow.document.fonts.ready,
860      ]);
861      await new Promise((resolve) =>
862        printWindow.requestAnimationFrame(() => printWindow.requestAnimationFrame(resolve))
863      );
864      if (frame.isConnected) {
865        printWindow.focus();
866        printWindow.print();
867      }
868    };
869    frame.srcdoc =
870      '<!doctype html><html lang="' +
871      esc(document.documentElement.lang || 'en') +
872      '" dir="' +
873      esc(document.documentElement.dir || 'ltr') +
874      '"><head><meta charset="utf-8"><title>Mobile Inventory ' +
875      esc(t('mi_demo_products', 'Demo products')) +
876      '</title><style>body{font:14px Arial,sans-serif;color:#222;padding:20px}h1{font-size:22px}table{border-collapse:collapse;width:100%}td,th{border:1px solid #ccc;padding:9px;text-align:start}th{background:#eee}tr{break-inside:avoid}img{display:block;width:50px;height:50px;object-fit:contain}</style></head><body><h1>Mobile Inventory: ' +
877      esc(t('mi_demo_products', 'Demo products')) +
878      '</h1><table><thead><tr>' +
879      rows[0].map((value) => '<th>' + esc(value) + '</th>').join('') +
880      '</tr></thead><tbody>' +
881      rows
882        .slice(1)
883        .map(
884          (row, index) =>
885            '<tr>' +
886            row
887              .map(
888                (value, column) =>
889                  '<td>' +
890                  (columns[column] === 'image'
891                    ? imageMarkup(printProducts[index], false).replace(' loading="lazy"', '')
892                    : esc(typeof value === 'number' ? number(value) : value)) +
893                  '</td>'
894              )
895              .join('') +
896            '</tr>'
897        )
898        .join('') +
899      '</tbody></table></body></html>';
900    document.body.appendChild(frame);
901  });
902  const columnPicker = $('#mi-demo-column-picker');
903  document.addEventListener('click', (event) => {
904    if (!columnPicker.contains(event.target)) columnPicker.open = false;
905  });
906  columnPicker.addEventListener('keydown', (event) => {
907    if (event.key === 'Escape') {
908      columnPicker.open = false;
909      columnPicker.querySelector('summary').focus();
910    }
911  });
912  document.addEventListener('hello:languagechange', refreshLabels);
913  refreshLabels();
914})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.