tosijs-ui 1.5.7 → 1.5.9

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.
@@ -288,8 +288,8 @@ export function createDocBrowser(options) {
288
288
  }
289
289
  testResultsResolve(allResults);
290
290
  testResultsResolve = undefined;
291
- // Post results to dev server on localhost
292
- if (isLocalhost) {
291
+ // Post results to dev server on localhost (not from test iframes)
292
+ if (isLocalhost && !isTestFrame) {
293
293
  fetch('/report', {
294
294
  method: 'POST',
295
295
  headers: { 'Content-Type': 'application/json' },
@@ -680,17 +680,46 @@ export function createDocBrowser(options) {
680
680
  lines.unshift(`**Summary: ${totalPassed} passed, ${totalFailed} failed**`, '');
681
681
  return lines.join('\n');
682
682
  }
683
+ // Detect if running as background test iframe
684
+ const searchParams = new URLSearchParams(window.location.search);
685
+ const isTestFrame = searchParams.get('_testMode') === '1';
686
+ const testFrameFilename = isTestFrame
687
+ ? window.location.search.substring(1).split('&')[0]
688
+ : null;
683
689
  // Listen for test completion events
684
690
  container.addEventListener('testcomplete', ((event) => {
685
691
  handleTestComplete(event);
686
692
  updateTestWidget();
693
+ // If running in test iframe, post results to parent
694
+ if (isTestFrame && window.parent !== window && testFrameFilename) {
695
+ const { results } = event.detail;
696
+ window.parent.postMessage({ type: 'tosi-test-results', filename: testFrameFilename, results }, '*');
697
+ }
687
698
  }));
699
+ // If running as test iframe, signal when all tests on this page are done
700
+ if (isTestFrame && testFrameFilename) {
701
+ const signalDone = () => {
702
+ const examples = container.querySelectorAll('tosi-example');
703
+ const withTests = [...examples].filter((ex) => ex.classList.contains('-has-tests'));
704
+ const running = withTests.filter((ex) => ex.classList.contains('-test-running'));
705
+ if (withTests.length > 0 && running.length === 0) {
706
+ window.parent.postMessage({ type: 'tosi-tests-done', filename: testFrameFilename }, '*');
707
+ }
708
+ else {
709
+ setTimeout(signalDone, 100);
710
+ }
711
+ };
712
+ // Give time for examples to start running
713
+ setTimeout(signalDone, 500);
714
+ }
688
715
  // Background test runner for all doc pages
689
716
  const runBackgroundTests = async () => {
690
717
  if (backgroundTestsStarted)
691
718
  return;
692
719
  if (!testManager.enabled.value)
693
720
  return;
721
+ if (isTestFrame)
722
+ return; // Don't run background tests in test iframe
694
723
  backgroundTestsStarted = true;
695
724
  // Find all docs that have test blocks
696
725
  const docsWithTests = docs.filter((doc) => doc.text.includes('```test'));
@@ -699,89 +728,71 @@ export function createDocBrowser(options) {
699
728
  setTestWidgetRunning();
700
729
  }
701
730
  if (pagesWithTests === 0) {
702
- // No tests to run, resolve immediately
703
731
  if (testResultsResolve) {
704
732
  testResultsResolve({ passed: 0, failed: 0, pages: {} });
705
733
  testResultsResolve = undefined;
706
734
  }
707
735
  return;
708
736
  }
709
- // Create a hidden iframe to run tests in background
737
+ const currentFilename = String(app.currentDoc.filename);
738
+ // Create a hidden iframe that loads the full page
710
739
  const testFrame = document.createElement('iframe');
711
740
  testFrame.style.cssText =
712
741
  'position: fixed; left: 0; top: 0; width: 800px; height: 600px; opacity: 0; pointer-events: none;';
713
742
  document.body.appendChild(testFrame);
714
- const currentFilename = String(app.currentDoc.filename);
743
+ // Listen for test results posted from the iframe
744
+ const messageHandler = (event) => {
745
+ if (event.data?.type !== 'tosi-test-results')
746
+ return;
747
+ const { filename, results } = event.data;
748
+ if (!pageTestResults[filename]) {
749
+ pageTestResults[filename] = {
750
+ passed: true,
751
+ tests: [],
752
+ totalPassed: 0,
753
+ totalFailed: 0,
754
+ };
755
+ }
756
+ const pageResults = pageTestResults[filename];
757
+ pageResults.tests.push(...results.tests);
758
+ pageResults.totalPassed += results.passed;
759
+ pageResults.totalFailed += results.failed;
760
+ pageResults.passed = pageResults.totalFailed === 0;
761
+ updateDocTestStatus(filename);
762
+ updateTestWidget();
763
+ };
764
+ window.addEventListener('message', messageHandler);
715
765
  for (const doc of docsWithTests) {
716
- // Skip current page - it will run tests naturally
717
- if (doc.filename === currentFilename) {
766
+ // Skip current page it runs tests naturally
767
+ if (doc.filename === currentFilename)
718
768
  continue;
719
- }
720
- // Reset page results for this doc
721
- pageTestResults[doc.filename] = {
722
- passed: true,
723
- tests: [],
724
- totalPassed: 0,
725
- totalFailed: 0,
726
- };
727
- // Create a container and render the doc content
728
- const testContainer = document.createElement('div');
729
- const viewer = tosiMd({
730
- value: doc.text,
731
- didRender() {
732
- LiveExample.insertExamples(this, context);
733
- },
769
+ // Navigate iframe to the page
770
+ const base = window.location.origin + window.location.pathname;
771
+ testFrame.src = `${base}?${doc.filename}&_testMode=1`;
772
+ // Wait for the iframe to signal it's done (max 30s per page)
773
+ await new Promise((resolve) => {
774
+ const deadline = Date.now() + 30_000;
775
+ const onDone = (event) => {
776
+ if (event.data?.type === 'tosi-tests-done' &&
777
+ event.data.filename === doc.filename) {
778
+ window.removeEventListener('message', onDone);
779
+ resolve();
780
+ }
781
+ };
782
+ window.addEventListener('message', onDone);
783
+ setTimeout(() => {
784
+ window.removeEventListener('message', onDone);
785
+ resolve();
786
+ }, deadline - Date.now());
734
787
  });
735
- testContainer.appendChild(viewer);
736
- // Listen for test results from this container
737
- const handleBgTest = (event) => {
738
- const { results } = event.detail;
739
- const pageResults = pageTestResults[doc.filename];
740
- pageResults.tests.push(...results.tests);
741
- pageResults.totalPassed += results.passed;
742
- pageResults.totalFailed += results.failed;
743
- pageResults.passed = pageResults.totalFailed === 0;
744
- updateDocTestStatus(doc.filename);
745
- updateTestWidget();
746
- };
747
- testContainer.addEventListener('testcomplete', handleBgTest);
748
- // Append to iframe for execution
749
- const frameDoc = testFrame.contentDocument;
750
- if (frameDoc) {
751
- frameDoc.body.innerHTML = '';
752
- frameDoc.body.appendChild(testContainer);
753
- // Wait for all live examples with tests to finish (max 30s per page)
754
- await new Promise((resolve) => {
755
- const deadline = Date.now() + 30_000;
756
- const checkDone = () => {
757
- if (Date.now() > deadline) {
758
- resolve();
759
- return;
760
- }
761
- const examples = testContainer.querySelectorAll('tosi-example');
762
- const withTests = [...examples].filter((ex) => ex.classList.contains('-has-tests'));
763
- const running = withTests.filter((ex) => ex.classList.contains('-test-running'));
764
- if (withTests.length > 0 && running.length === 0) {
765
- resolve();
766
- }
767
- else {
768
- setTimeout(checkDone, 100);
769
- }
770
- };
771
- // Give initial render time to start
772
- setTimeout(checkDone, 200);
773
- });
774
- }
775
788
  markPageTested(doc.filename);
776
789
  }
777
790
  // Clean up
791
+ window.removeEventListener('message', messageHandler);
778
792
  testFrame.remove();
779
793
  // Mark current page as tested if it has tests
780
794
  if (docsWithTests.some((d) => d.filename === currentFilename)) {
781
- // Current page tests will complete naturally, just wait a bit
782
- setTimeout(() => {
783
- markPageTested(currentFilename);
784
- }, 1000);
795
+ setTimeout(() => markPageTested(currentFilename), 1000);
785
796
  }
786
797
  };
787
798
  // Run background tests when enabled (initially or when toggled on)
package/dist/icons.d.ts CHANGED
@@ -8,6 +8,8 @@ export interface IconRule {
8
8
  prefix: string | RegExp;
9
9
  apply: (baseName: string, match: RegExpMatchArray | string, parts: ElementPart[]) => Element | string | null;
10
10
  }
11
+ /** Returns icon name safe for suffix concatenation (appends _ if name ends in digit) */
12
+ export declare const safeIconSuffix: (name: string) => string;
11
13
  export declare const iconRules: IconRule[];
12
14
  export declare const icons: SVGIconMap;
13
15
  export declare class SvgIcon extends WebComponent {
package/dist/icons.js CHANGED
@@ -7,26 +7,6 @@
7
7
  <tosi-icon title="tosi-platform" icon="tosiPlatform" style="--tosi-icon-size: 128px"></tosi-icon>
8
8
  </div>
9
9
 
10
- A library that provides `ElementCreator` functions that produce SVG icons. It leverages `tosijs`'s
11
- `svgElements` proxy and is intended to address all the key use-cases for SVG icons in web
12
- applications along with being very easy to extend and maintain.
13
-
14
- > ### Supported Use Cases
15
- > - inline SVGs that can be styled by CSS (for buttons, etc.)
16
- > - allows both stroked and filled icons (unlike font-based systems)
17
- > - support for color icons (without requiring multiple glyphs perfectly aligned)
18
- > - icons can be rendered as data urls, e.g. to insert into CSS… (the little `owl` logo rendered under blockquotes is an example)
19
- > - icon composition: stack, transform, and style icons via naming conventions (e.g. `lock50s75o$shield`)
20
- > - extensible rules system for custom prefixes and overlays (e.g. `unLock`, `checkFile`)
21
- > - icon redirects eliminate redundant SVG data (e.g. `chevronDown` → `chevronRight90r`)
22
-
23
- ### Nice Features
24
- > - no build process magic needed (your icons are "just javascript", no special CSS files needed, no magic glyph mappings). Adding new, or overriding existing, icons is trivial.
25
- > - icons are just regular SVG, not a specialized subset.
26
- > - highly optimized and compressible (the code is comparable in size to what you get with a compressed font built from the same icons, except icon fonts don't support strokes, gradients, etc.)
27
-
28
- ## icons
29
-
30
10
  `icons` is a proxy that generates an `ElementCreator` for a given icon on demand,
31
11
  e.g. `icons.chevronDown()` produces an `<svg>` element containing a downward-pointing chevron
32
12
  icon with the class `icon-chevron-down`.
@@ -351,7 +331,7 @@ that, for example, treat all colored icons inside buttons the same way.
351
331
  <tosi-icon icon="lock50s75o_10y$shield_brandColorS" size=128></tosi-icon>
352
332
  <tosi-icon icon="unLock_brandColorS" size=128></tosi-icon>
353
333
  <tosi-icon icon="checkFile" size=128></tosi-icon>
354
- <tosi-icon icon="spin120Loader40s_30x$cloud" size=128></tosi-icon>
334
+ <tosi-icon icon="spin120Loader40s_30x_brandColorS$cloud" size=128></tosi-icon>
355
335
 
356
336
  ### Why?
357
337
 
@@ -371,6 +351,12 @@ variations on every icon?
371
351
  <tosi-icon icon="pin0f_brandColorS" size=64></tosi-icon>
372
352
  <tosi-icon icon="unPin_brandColorS" size=64></tosi-icon>
373
353
 
354
+ And now we can just create icon languages…
355
+
356
+ <tosi-icon icon="checkUsers" size=64></tosi-icon>
357
+ <tosi-icon icon="searchMap" size=64></tosi-icon>
358
+ <tosi-icon icon="cancelHeart" size=64></tosi-icon>
359
+
374
360
  ### Icon modifier suffixes
375
361
 
376
362
  The suffix system is inspired by tosijs's CSS variable math, where
@@ -473,35 +459,6 @@ with `svg2DataUrl`.
473
459
  If you ask for an icon that isn't defined, the `icons` proxy will print a warning to console
474
460
  and render a `square` (in fact, `icons.square()`) as a fallback.
475
461
 
476
- ## Why?
477
-
478
- My evolution has been:
479
-
480
- 1. Using Icomoon.io, which I still think is a solid choice for managing custom icon fonts
481
- 2. Processing Icomoon selection.json files into icon-data and then generating SVGs dynamically
482
- from the data
483
- 3. Ingesting SVGs directly, with a little cleanup
484
-
485
- The goal is always to have a single source of truth for icons, no magic or convoluted tooling, and
486
- be able to quickly and easily add and replace icons, distribute them with components, and
487
- have no mess or fuss.
488
-
489
- 1. Works well, but…
490
- - color icons are flaky,
491
- - doesn't play well with others,
492
- - can't really distribute the icons with your components.
493
- - difficult to use icons in CSS `content`
494
- - impossible to use icons in CSS backgrounds
495
- 2. This is `icons.ts` until just now! Solves all the above, but…
496
- - no fancy SVG effects, like gradients (goodness knows I experimented with converting CSS gradients to SVG gradients) and, most
497
- - **strokes** need to be converted to outlines
498
- - outlined strokes can't be styled the way strokes can
499
- - blocks use of popular icon libraries
500
- 3. This is how everyone else works, except…
501
- - no build magic needed: `defineIcons({ myIcon: '<svg....>', ... })`
502
- - if you want build magic, `icons.js` has no dependencies, finds icons and creates an `icon-data.ts` file.
503
- - smaller icon files, even though I'm now including more icons (including *all the current* feathericons)
504
-
505
462
  ## Icon Sources
506
463
 
507
464
  Many of these icons are sourced from [Feather Icons](https://github.com/feathericons/feather), but
@@ -514,6 +471,12 @@ The remaining icons I have created myself using the excellent but sometimes flaw
514
471
  [Amadine](https://apps.apple.com/us/app/amadine-vector-design-art/id1339198386?mt=12)
515
472
  and before that [Graphic](https://apps.apple.com/us/app/graphic/id404705039?mt=12).
516
473
 
474
+ ### Building icon data
475
+
476
+ Use [make-icon-data](/?make-icon-data.js) to generate icon data from SVG files.
477
+ It supports directional folder conventions to auto-generate rotated and flipped
478
+ variants, eliminating redundant SVGs.
479
+
517
480
  ### Feather Icons Copyright Notice
518
481
 
519
482
  The MIT License (MIT)
@@ -573,6 +536,8 @@ export const svg2DataUrl = (icon, fill, stroke, strokeWidth) => {
573
536
  const text = encodeURIComponent(svg.outerHTML);
574
537
  return `url(data:image/svg+xml;charset=UTF-8,${text})`;
575
538
  };
539
+ /** Returns icon name safe for suffix concatenation (appends _ if name ends in digit) */
540
+ export const safeIconSuffix = (name) => /\d$/.test(name) ? name + '_' : name;
576
541
  export const iconRules = [
577
542
  {
578
543
  prefix: /^spin(_?\d+)/,
@@ -580,40 +545,38 @@ export const iconRules = [
580
545
  const dps = match[1].replace('_', '-');
581
546
  const duration = 360 / Math.abs(parseFloat(dps));
582
547
  const direction = dps.startsWith('-') ? 'reverse' : 'normal';
583
- // Strip suffixes — apply them to the wrapper, not the inner icon
584
- const parsed = parseStyleSuffixes(baseName);
585
- const iconName = parsed ? parsed.baseName : baseName;
586
- const icon = resolveIcon(iconName, []);
548
+ // Resolve baseName with suffixes intact they apply to the icon
549
+ const icon = resolveIcon(baseName, []);
587
550
  const el = icon;
588
551
  if (el.animate) {
589
- el.animate([{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }], {
552
+ const base = el.style.transform || '';
553
+ el.animate([
554
+ { transform: `${base} rotate(0deg)` },
555
+ { transform: `${base} rotate(360deg)` },
556
+ ], {
590
557
  duration: duration * 1000,
591
558
  iterations: Infinity,
592
559
  direction,
593
560
  });
594
561
  }
595
- const wrapper = wrapIcon(baseName, parts, icon);
596
- if (parsed) {
597
- Object.assign(wrapper.style, parsed.style);
598
- }
599
- return wrapper;
562
+ return wrapIcon(baseName, parts, icon);
600
563
  },
601
564
  },
602
565
  {
603
566
  prefix: 'un',
604
- apply: (baseName) => `slash25o$${baseName}75s75o`,
567
+ apply: (baseName) => `slash25o$${safeIconSuffix(baseName)}75s75o`,
605
568
  },
606
569
  {
607
570
  prefix: 'check',
608
- apply: (baseName) => `check75o_00aa00S$${baseName}75s50o`,
571
+ apply: (baseName) => `check75o_00aa00S$${safeIconSuffix(baseName)}75s50o`,
609
572
  },
610
573
  {
611
574
  prefix: 'cancel',
612
- apply: (baseName) => `x75o_cc0000S$${baseName}75s50o`,
575
+ apply: (baseName) => `x75o_cc0000S$${safeIconSuffix(baseName)}75s50o`,
613
576
  },
614
577
  {
615
578
  prefix: 'search',
616
- apply: (baseName) => `search80s30x30y$${baseName}50o`,
579
+ apply: (baseName) => `search80s30x30y$${safeIconSuffix(baseName)}50o`,
617
580
  },
618
581
  ];
619
582
  function makeIcon(spec, parts) {
@@ -706,21 +669,41 @@ function composeIcon(prop, parts) {
706
669
  return null;
707
670
  }
708
671
  const MAX_REDIRECTS = 10;
709
- // Style suffixes — always value then letter code:
672
+ // Style suffixes — value then letter code:
710
673
  // 50o (opacity), 75s (scale), 20x (translateX%), _10y (translateY%)
711
674
  // 90r (rotate 90°), _45r (rotate -45°), 0f (flipH), 1f (flipV)
712
675
  // _FF0000F (fill hex), _f00S (stroke hex), 3W (stroke-width)
713
676
  // _brandColorF (fill var), _accentS (stroke var)
714
- const SUFFIX_RE = /(_?\d{2,3}[osxyr]|[01]f|_[a-zA-Z0-9]+[FS]|\d{1,3}W)+$/;
677
+ // Icon names ending in digits need _ separator: edit2_50o
678
+ const SUFFIX_RE = /(?:(?<=[a-zA-Z_])(?:_?\d+[osxyr]|[01]f|\d+W)|_[a-zA-Z0-9]+[FS])+$/;
715
679
  function parseStyleSuffixes(name) {
716
680
  const match = name.match(SUFFIX_RE);
717
681
  if (!match)
718
682
  return null;
719
- const baseName = name.slice(0, match.index);
683
+ let baseName = name.slice(0, match.index);
720
684
  if (!baseName)
721
685
  return null;
686
+ // Strip trailing _ separator (for digit-ending names: edit2_50o)
687
+ if (baseName.endsWith('_'))
688
+ baseName = baseName.slice(0, -1);
689
+ if (!baseName)
690
+ return null;
691
+ const data = iconData;
692
+ // Accept if baseName is in iconData, or matches a composition rule prefix
693
+ if (!data[baseName]) {
694
+ const matchesRule = iconRules.some((rule) => {
695
+ if (rule.prefix instanceof RegExp) {
696
+ return rule.prefix.test(baseName);
697
+ }
698
+ return baseName.startsWith(rule.prefix) && baseName.length > rule.prefix.length;
699
+ });
700
+ if (!matchesRule)
701
+ return null;
702
+ }
722
703
  const style = {};
723
- const suffixes = match[0].match(/_?\d{2,3}[osxyr]|[01]f|_[a-zA-Z0-9]+[FS]|\d{1,3}W/g);
704
+ const suffixes = match[0].match(/_?\d+[osxyr]|[01]f|_[a-zA-Z0-9]+[FS]|\d+W/g);
705
+ if (!suffixes)
706
+ return null;
724
707
  let tx = '';
725
708
  let ty = '';
726
709
  let scale = '';