@cfasim-ui/docs 0.4.16 → 0.5.1

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.
@@ -21,7 +21,22 @@ export interface ColumnConfig {
21
21
  label?: string;
22
22
  width?: ColumnWidth | number;
23
23
  align?: ColumnAlign;
24
+ /** Class applied to every body `<td>` in this column. */
24
25
  cellClass?: string;
26
+ /**
27
+ * Class applied to both the header `<th>` and every body `<td>` in
28
+ * this column. Use for styling that should span the whole column
29
+ * (borders, backgrounds, fonts). For body-only styling, use
30
+ * `cellClass`.
31
+ */
32
+ columnClass?: string;
33
+ /**
34
+ * Allow cell contents to wrap to multiple lines when wider than the
35
+ * column. Default `false` — cells stay on one line and truncate with
36
+ * an ellipsis. Set to `true` for text-heavy columns where the full
37
+ * value should remain visible.
38
+ */
39
+ wrap?: boolean;
25
40
  /**
26
41
  * Custom formatter for cell values in this column. Accepts a
27
42
  * {@link NumberFormat} (preset name, printf-style string, or
@@ -104,6 +119,20 @@ function columnAlignStyle(name: string): CSSProperties | undefined {
104
119
  return { textAlign: align };
105
120
  }
106
121
 
122
+ /** Classes shared by `<th>` and `<td>` for a column: `columnClass` + wrap flag. */
123
+ function columnSharedClass(name: string): (string | undefined)[] {
124
+ const cfg = props.columnConfig?.[name];
125
+ return [cfg?.columnClass, cfg?.wrap ? "cell-wrap" : undefined];
126
+ }
127
+
128
+ function headerCellClass(name: string): (string | undefined)[] {
129
+ return columnSharedClass(name);
130
+ }
131
+
132
+ function bodyCellClass(name: string): (string | undefined)[] {
133
+ return [...columnSharedClass(name), props.columnConfig?.[name]?.cellClass];
134
+ }
135
+
107
136
  function isModelOutput(d: TableData): d is ModelOutput {
108
137
  return typeof (d as ModelOutput).column === "function";
109
138
  }
@@ -231,6 +260,7 @@ const showMenu = computed(
231
260
  <th
232
261
  v-for="col in columns"
233
262
  :key="col.name"
263
+ :class="headerCellClass(col.name)"
234
264
  :style="columnAlignStyle(col.name)"
235
265
  >
236
266
  {{ columnLabel(col.name) }}
@@ -242,7 +272,7 @@ const showMenu = computed(
242
272
  <td
243
273
  v-for="col in columns"
244
274
  :key="col.name"
245
- :class="columnConfig?.[col.name]?.cellClass"
275
+ :class="bodyCellClass(col.name)"
246
276
  :style="columnAlignStyle(col.name)"
247
277
  >
248
278
  {{ cellValue(col, row - 1) }}
@@ -309,9 +339,18 @@ const showMenu = computed(
309
339
  .Table td {
310
340
  padding: 0.75em 1.25em;
311
341
  white-space: nowrap;
342
+ overflow: hidden;
343
+ text-overflow: ellipsis;
312
344
  text-align: left;
313
345
  }
314
346
 
347
+ .Table th.cell-wrap,
348
+ .Table td.cell-wrap {
349
+ white-space: normal;
350
+ overflow: visible;
351
+ text-overflow: clip;
352
+ }
353
+
315
354
  .Table th {
316
355
  font-weight: 600;
317
356
  border-bottom: 1px solid var(--color-border-header);
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Color helpers for picking a readable text color against a bar fill.
3
+ * The luminance math is pure and unit-testable; resolving CSS values
4
+ * that aren't plain hex/rgb (named colors, `var(--x)`) needs the DOM and
5
+ * is handled separately by {@link resolveColorToRgb}.
6
+ */
7
+
8
+ export type Rgb = [number, number, number];
9
+
10
+ /**
11
+ * Parse a hex (`#rgb`, `#rrggbb`, `#rrggbbaa`) or `rgb()/rgba()` string to
12
+ * `[r, g, b]` in 0..255. Returns null for anything else (named colors,
13
+ * `var(...)`, `hsl(...)`) — resolve those via {@link resolveColorToRgb}.
14
+ */
15
+ export function parseRgb(color: string): Rgb | null {
16
+ const c = color.trim();
17
+ if (c.startsWith("#")) {
18
+ let hex = c.slice(1);
19
+ if (hex.length === 3 || hex.length === 4) {
20
+ hex = hex
21
+ .slice(0, 3)
22
+ .split("")
23
+ .map((ch) => ch + ch)
24
+ .join("");
25
+ } else if (hex.length === 6 || hex.length === 8) {
26
+ hex = hex.slice(0, 6);
27
+ } else {
28
+ return null;
29
+ }
30
+ const n = Number.parseInt(hex, 16);
31
+ if (Number.isNaN(n)) return null;
32
+ return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
33
+ }
34
+ const m = c.match(/^rgba?\(([^)]+)\)$/i);
35
+ if (m) {
36
+ const parts = m[1].split(/[,/\s]+/).filter(Boolean);
37
+ if (parts.length < 3) return null;
38
+ const rgb = parts.slice(0, 3).map((p) => {
39
+ if (p.endsWith("%")) return Math.round((parseFloat(p) / 100) * 255);
40
+ return Math.round(parseFloat(p));
41
+ });
42
+ if (rgb.some((v) => Number.isNaN(v))) return null;
43
+ return [rgb[0], rgb[1], rgb[2]];
44
+ }
45
+ return null;
46
+ }
47
+
48
+ /** WCAG relative luminance (0 = black, 1 = white) for an RGB triple. */
49
+ export function relativeLuminance([r, g, b]: Rgb): number {
50
+ const channel = (v: number) => {
51
+ const s = v / 255;
52
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
53
+ };
54
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
55
+ }
56
+
57
+ let probe: HTMLSpanElement | null = null;
58
+ const resolveCache = new Map<string, Rgb | null>();
59
+
60
+ /**
61
+ * Resolve any CSS color string (including named colors and `var(--x)`) to
62
+ * an RGB triple by letting the browser compute it on a hidden probe
63
+ * element. Cached per input. Returns null in non-DOM environments or when
64
+ * the value can't be resolved. Plain hex/rgb shortcut through
65
+ * {@link parseRgb} without touching the DOM.
66
+ */
67
+ export function resolveColorToRgb(color: string): Rgb | null {
68
+ const direct = parseRgb(color);
69
+ if (direct) return direct;
70
+ if (resolveCache.has(color)) return resolveCache.get(color) ?? null;
71
+ if (typeof document === "undefined") return null;
72
+ if (!probe) {
73
+ probe = document.createElement("span");
74
+ probe.style.cssText =
75
+ "position:absolute;width:0;height:0;visibility:hidden;pointer-events:none";
76
+ document.body.appendChild(probe);
77
+ }
78
+ probe.style.color = "";
79
+ probe.style.color = color;
80
+ const computed = getComputedStyle(probe).color;
81
+ const rgb = parseRgb(computed);
82
+ resolveCache.set(color, rgb);
83
+ return rgb;
84
+ }
85
+
86
+ /**
87
+ * Pick the more readable of `light`/`dark` text colors for a given fill.
88
+ * Uses the WCAG luminance threshold (0.179) that maximizes contrast
89
+ * against black-or-white text. When the fill can't be resolved (e.g. an
90
+ * unresolvable CSS var in a non-DOM context), falls back to `light`.
91
+ */
92
+ export function pickContrastColor(
93
+ fill: string,
94
+ light = "#ffffff",
95
+ dark = "#1a1a1a",
96
+ ): string {
97
+ const rgb = resolveColorToRgb(fill);
98
+ if (!rgb) return light;
99
+ return relativeLuminance(rgb) > 0.179 ? dark : light;
100
+ }
@@ -54,6 +54,13 @@ export type {
54
54
  BlendMode,
55
55
  LineMarkStyle,
56
56
  } from "./chartProps.js";
57
+ export {
58
+ parseRgb,
59
+ relativeLuminance,
60
+ resolveColorToRgb,
61
+ pickContrastColor,
62
+ type Rgb,
63
+ } from "./contrast.js";
57
64
  export {
58
65
  parseDate,
59
66
  isDateLike,
@@ -0,0 +1,4 @@
1
+ // Ambient declaration for side-effect CSS imports (e.g. `import "./x.css"`).
2
+ // Required since TypeScript 6, which errors (TS2882) on side-effect imports
3
+ // without a module declaration.
4
+ declare module "*.css";
@@ -1,6 +1,14 @@
1
1
  # Icon
2
2
 
3
- Renders a [Material Symbols Outlined](https://fonts.google.com/icons) icon.
3
+ Renders a [Material Symbols](https://fonts.google.com/icons) icon as an inline
4
+ SVG. Icons are resolved from a registry by name, so you reference them exactly as
5
+ before — `<Icon icon="help" />` — but no webfont is loaded: only the SVGs you
6
+ actually use end up in your bundle.
7
+
8
+ A [base set](#base-set) of common icons is pre-registered and works with zero
9
+ setup. These SVGs are bundled inside `cfasim-ui`, so they need no extra install
10
+ or config — just `<Icon icon="help" />`. To use any other icon,
11
+ [register it](#registering-icons).
4
12
 
5
13
  ## Examples
6
14
 
@@ -52,6 +60,119 @@ Renders a [Material Symbols Outlined](https://fonts.google.com/icons) icon.
52
60
  </template>
53
61
  </ComponentDemo>
54
62
 
63
+ ## Base set
64
+
65
+ These icons are bundled with `cfasim-ui` and need no install or registration —
66
+ just pass the name to `icon`. Anything not listed here must be
67
+ [registered](#registering-icons).
68
+
69
+ <script setup>
70
+ const baseIcons = [
71
+ "help", "favorite", "dark_mode", "light_mode",
72
+ "chevron_left", "chevron_right", "keyboard_arrow_up", "keyboard_arrow_down",
73
+ "keyboard_double_arrow_left", "keyboard_double_arrow_right",
74
+ "arrow_upward", "arrow_downward", "arrow_back", "arrow_forward",
75
+ "menu", "more_vert", "more_horiz",
76
+ "add", "edit", "delete", "close", "check", "search", "tune", "download",
77
+ "github",
78
+ ];
79
+ </script>
80
+
81
+ <Grid class="base-icons" :cols="4" :colsSmall="2" gap="sm">
82
+ <div v-for="name in baseIcons" :key="name" class="base-icons__cell">
83
+ <Icon :icon="name" size="lg" :aria-label="name" />
84
+ <code>{{ name }}</code>
85
+ </div>
86
+ </Grid>
87
+
88
+ <style>
89
+ .base-icons__cell {
90
+ display: flex;
91
+ flex-direction: column;
92
+ align-items: center;
93
+ gap: 0.5rem;
94
+ padding: 0.75rem 0.5rem;
95
+ border: 1px solid var(--vp-c-divider);
96
+ border-radius: 8px;
97
+ text-align: center;
98
+ }
99
+ .base-icons__cell code {
100
+ font-size: 0.65rem;
101
+ word-break: break-word;
102
+ }
103
+ </style>
104
+
105
+ `favorite` also has a filled variant via `:fill="true"`.
106
+
107
+ ## Registering icons
108
+
109
+ The base set above is bundled with `cfasim-ui` and needs none of the steps below
110
+ — this section is only for additional icons. `cfasim-ui` does not depend on
111
+ `@material-symbols/svg-400`, so you install the icons yourself; each one you
112
+ import is inlined into _your_ bundle (still no webfont).
113
+
114
+ ### 1. Install the icons and the Vite SVG loader
115
+
116
+ ```sh
117
+ pnpm add -D @material-symbols/svg-400 vite-svg-loader
118
+ ```
119
+
120
+ ### 2. Add `svgLoader` to your Vite config
121
+
122
+ Add it to your existing `plugins` array — leave the other plugins in place:
123
+
124
+ ```ts
125
+ // vite.config.ts
126
+ import svgLoader from "vite-svg-loader";
127
+
128
+ export default defineConfig({
129
+ plugins: [
130
+ // ...your existing plugins
131
+ svgLoader(),
132
+ ],
133
+ });
134
+ ```
135
+
136
+ ### 3. Register the icons once at startup
137
+
138
+ Import each SVG from the `outlined` style with the `?component` query, then pass
139
+ them to `registerIcons`. The key you register is the name you pass to `icon`.
140
+
141
+ ```ts
142
+ // main.ts
143
+ import rocket from "@material-symbols/svg-400/outlined/rocket.svg?component";
144
+ import settings from "@material-symbols/svg-400/outlined/settings.svg?component";
145
+ import { registerIcons } from "@cfasim-ui/components";
146
+
147
+ registerIcons({ rocket, settings });
148
+ ```
149
+
150
+ ### 4. Use them by name
151
+
152
+ ```vue
153
+ <Icon icon="rocket" />
154
+ <Icon icon="settings" size="lg" />
155
+ ```
156
+
157
+ An unregistered name renders nothing and logs a one-time console warning with the
158
+ exact code to register it.
159
+
160
+ ### Filled variants
161
+
162
+ To support `:fill="true"` on a registered icon, register both the outline and
163
+ `-fill` files:
164
+
165
+ ```ts
166
+ import favorite from "@material-symbols/svg-400/outlined/favorite.svg?component";
167
+ import favoriteFill from "@material-symbols/svg-400/outlined/favorite-fill.svg?component";
168
+
169
+ registerIcons({ favorite: { outline: favorite, fill: favoriteFill } });
170
+ ```
171
+
172
+ > The `weight` and `grade` props are deprecated no-ops — static SVGs ship at
173
+ > weight 400, grade 0. `size`, `fill`, `inline`, and color (`currentColor`) work
174
+ > as before.
175
+
55
176
  ## Props
56
177
 
57
178
  | Prop | Type | Required | Default |
@@ -1,6 +1,12 @@
1
1
  <script setup lang="ts">
2
2
  import type { CSSProperties } from "vue";
3
3
  import { computed } from "vue";
4
+ import { getIconComponent, warnMissingIcon } from "./registry";
5
+ import { registerDefaultIcons } from "./defaultIcons";
6
+
7
+ // Driven from setup (a retained code path) so the base set survives
8
+ // tree-shaking; idempotent, so the per-instance call is effectively free.
9
+ registerDefaultIcons();
4
10
 
5
11
  export type IconSize = "sm" | "md" | "lg" | "xl";
6
12
 
@@ -8,7 +14,9 @@ interface Props {
8
14
  icon: string;
9
15
  size?: IconSize | number;
10
16
  fill?: boolean;
17
+ /** @deprecated No effect — SVG icons ship at weight 400. */
11
18
  weight?: number;
19
+ /** @deprecated No effect — SVG icons ship at grade 0. */
12
20
  grade?: number;
13
21
  decorative?: boolean;
14
22
  ariaLabel?: string;
@@ -25,23 +33,17 @@ const props = withDefaults(defineProps<Props>(), {
25
33
  const sizePreset = computed(() =>
26
34
  typeof props.size === "string" ? props.size : undefined,
27
35
  );
28
- const numericSize = computed(() =>
29
- typeof props.size === "number" ? props.size : undefined,
36
+
37
+ const inlineStyle = computed<CSSProperties>(() =>
38
+ typeof props.size === "number"
39
+ ? { width: `${props.size}px`, height: `${props.size}px` }
40
+ : {},
30
41
  );
31
42
 
32
- const inlineStyle = computed<CSSProperties>(() => {
33
- const style: CSSProperties = {};
34
- if (numericSize.value !== undefined) {
35
- style.fontSize = `${numericSize.value}px`;
36
- (style as Record<string, unknown>)["--icon-opsz"] = numericSize.value;
37
- }
38
- if (props.weight !== undefined) {
39
- (style as Record<string, unknown>)["--icon-weight"] = props.weight;
40
- }
41
- if (props.grade !== undefined) {
42
- (style as Record<string, unknown>)["--icon-grade"] = props.grade;
43
- }
44
- return style;
43
+ const svg = computed(() => {
44
+ const component = getIconComponent(props.icon, props.fill);
45
+ if (!component) warnMissingIcon(props.icon);
46
+ return component;
45
47
  });
46
48
  </script>
47
49
 
@@ -49,64 +51,54 @@ const inlineStyle = computed<CSSProperties>(() => {
49
51
  <span
50
52
  class="Icon"
51
53
  :data-size="sizePreset"
52
- :data-fill="fill ? 'true' : undefined"
53
54
  :data-inline="inline ? 'true' : undefined"
54
55
  :style="inlineStyle"
55
56
  :aria-hidden="decorative ? true : undefined"
56
57
  :aria-label="decorative ? undefined : ariaLabel"
57
58
  :role="decorative ? undefined : 'img'"
58
- >{{ icon }}</span
59
59
  >
60
+ <component :is="svg" v-if="svg" />
61
+ </span>
60
62
  </template>
61
63
 
62
64
  <style>
63
65
  .Icon {
64
- font-family: "Material Symbols Outlined", sans-serif;
65
- font-weight: normal;
66
- font-style: normal;
67
- font-size: 24px;
68
- line-height: 1;
69
- letter-spacing: normal;
70
- text-transform: none;
71
- display: inline-block;
72
- white-space: nowrap;
73
- word-wrap: normal;
74
- direction: ltr;
75
- font-feature-settings: "liga";
76
- -webkit-font-smoothing: antialiased;
77
- font-variation-settings:
78
- "FILL" var(--icon-fill, 0),
79
- "wght" var(--icon-weight, 400),
80
- "GRAD" var(--icon-grade, 0),
81
- "opsz" var(--icon-opsz, 24);
66
+ display: inline-flex;
67
+ align-items: center;
68
+ justify-content: center;
69
+ flex: none;
70
+ width: 24px;
71
+ height: 24px;
82
72
  color: inherit;
83
73
  }
84
74
 
75
+ .Icon svg {
76
+ display: block;
77
+ width: 100%;
78
+ height: 100%;
79
+ fill: currentColor;
80
+ }
81
+
85
82
  .Icon[data-size="sm"] {
86
- font-size: 20px;
87
- --icon-opsz: 20;
83
+ width: 20px;
84
+ height: 20px;
88
85
  }
89
86
  .Icon[data-size="md"] {
90
- font-size: 24px;
91
- --icon-opsz: 24;
87
+ width: 24px;
88
+ height: 24px;
92
89
  }
93
90
  .Icon[data-size="lg"] {
94
- font-size: 28px;
95
- --icon-opsz: 28;
91
+ width: 28px;
92
+ height: 28px;
96
93
  }
97
94
  .Icon[data-size="xl"] {
98
- font-size: 32px;
99
- --icon-opsz: 32;
100
- }
101
-
102
- .Icon[data-fill="true"] {
103
- --icon-fill: 1;
95
+ width: 32px;
96
+ height: 32px;
104
97
  }
105
98
 
106
99
  .Icon[data-inline="true"] {
107
- font-size: inherit;
108
- vertical-align: middle;
109
- transform: scale(1.2) translateY(-0.05em);
110
- transform-origin: 50% 50%;
100
+ width: 1em;
101
+ height: 1em;
102
+ vertical-align: -0.125em;
111
103
  }
112
104
  </style>
@@ -0,0 +1,92 @@
1
+ // The base icon set, registered on first use of <Icon> so `icon="..."` works
2
+ // with zero setup. These are bundled into the published package (each import
3
+ // inlines a ~0.3-0.6 KB SVG at build time), so consumers need no install.
4
+ //
5
+ // Registration is driven from Icon.vue's setup (a used code path) rather than a
6
+ // bare side-effect import, so it survives tree-shaking of this side-effect-free
7
+ // package.
8
+ //
9
+ // Keep this list and the "Base set" table in Icon.md in sync.
10
+ import help from "@material-symbols/svg-400/outlined/help.svg?component";
11
+ import favorite from "@material-symbols/svg-400/outlined/favorite.svg?component";
12
+ import favorite_fill from "@material-symbols/svg-400/outlined/favorite-fill.svg?component";
13
+ import dark_mode from "@material-symbols/svg-400/outlined/dark_mode.svg?component";
14
+ import light_mode from "@material-symbols/svg-400/outlined/light_mode.svg?component";
15
+ // chevrons / navigation
16
+ import chevron_left from "@material-symbols/svg-400/outlined/chevron_left.svg?component";
17
+ import chevron_right from "@material-symbols/svg-400/outlined/chevron_right.svg?component";
18
+ import keyboard_arrow_up from "@material-symbols/svg-400/outlined/keyboard_arrow_up.svg?component";
19
+ import keyboard_arrow_down from "@material-symbols/svg-400/outlined/keyboard_arrow_down.svg?component";
20
+ import keyboard_double_arrow_left from "@material-symbols/svg-400/outlined/keyboard_double_arrow_left.svg?component";
21
+ import keyboard_double_arrow_right from "@material-symbols/svg-400/outlined/keyboard_double_arrow_right.svg?component";
22
+ // arrows (all directions)
23
+ import arrow_upward from "@material-symbols/svg-400/outlined/arrow_upward.svg?component";
24
+ import arrow_downward from "@material-symbols/svg-400/outlined/arrow_downward.svg?component";
25
+ import arrow_back from "@material-symbols/svg-400/outlined/arrow_back.svg?component";
26
+ import arrow_forward from "@material-symbols/svg-400/outlined/arrow_forward.svg?component";
27
+ // menus
28
+ import menu from "@material-symbols/svg-400/outlined/menu.svg?component";
29
+ import more_vert from "@material-symbols/svg-400/outlined/more_vert.svg?component";
30
+ import more_horiz from "@material-symbols/svg-400/outlined/more_horiz.svg?component";
31
+ // actions
32
+ import add from "@material-symbols/svg-400/outlined/add.svg?component";
33
+ import edit from "@material-symbols/svg-400/outlined/edit.svg?component";
34
+ import deleteIcon from "@material-symbols/svg-400/outlined/delete.svg?component";
35
+ import close from "@material-symbols/svg-400/outlined/close.svg?component";
36
+ import check from "@material-symbols/svg-400/outlined/check.svg?component";
37
+ import search from "@material-symbols/svg-400/outlined/search.svg?component";
38
+ import tune from "@material-symbols/svg-400/outlined/tune.svg?component";
39
+ import download from "@material-symbols/svg-400/outlined/download.svg?component";
40
+ // brand logos (not part of Material Symbols)
41
+ import github from "./github.svg?component";
42
+ import type { IconRegistration } from "./registry";
43
+ import { registerIcons, hasIcon } from "./registry";
44
+
45
+ const defaults: Record<string, IconRegistration> = {
46
+ // status / theme
47
+ help,
48
+ favorite: { outline: favorite, fill: favorite_fill },
49
+ dark_mode,
50
+ light_mode,
51
+ // chevrons / navigation
52
+ chevron_left,
53
+ chevron_right,
54
+ keyboard_arrow_up,
55
+ keyboard_arrow_down,
56
+ keyboard_double_arrow_left,
57
+ keyboard_double_arrow_right,
58
+ // arrows (all directions)
59
+ arrow_upward,
60
+ arrow_downward,
61
+ arrow_back,
62
+ arrow_forward,
63
+ // menus
64
+ menu,
65
+ more_vert,
66
+ more_horiz,
67
+ // actions
68
+ add,
69
+ edit,
70
+ delete: deleteIcon,
71
+ close,
72
+ check,
73
+ search,
74
+ tune,
75
+ download,
76
+ // brand logos
77
+ github,
78
+ };
79
+
80
+ let registered = false;
81
+
82
+ /**
83
+ * Register the base icon set once. A name already registered by the consumer is
84
+ * left untouched, so explicit `registerIcons` calls always win over defaults.
85
+ */
86
+ export function registerDefaultIcons(): void {
87
+ if (registered) return;
88
+ registered = true;
89
+ for (const [name, reg] of Object.entries(defaults)) {
90
+ if (!hasIcon(name)) registerIcons({ [name]: reg });
91
+ }
92
+ }
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>
@@ -0,0 +1,68 @@
1
+ import type { Component } from "vue";
2
+
3
+ /** A registered icon. Provide a bare component (outline only) or both variants. */
4
+ export interface IconVariants {
5
+ outline: Component;
6
+ fill?: Component;
7
+ }
8
+
9
+ export type IconRegistration = Component | IconVariants;
10
+
11
+ const registry = new Map<string, IconVariants>();
12
+
13
+ function toVariants(reg: IconRegistration): IconVariants {
14
+ return typeof reg === "object" && reg !== null && "outline" in reg
15
+ ? (reg as IconVariants)
16
+ : { outline: reg as Component };
17
+ }
18
+
19
+ /**
20
+ * Register icons for use with `<Icon icon="name" />`. Call once at app startup.
21
+ * Re-registering a name overwrites the previous entry.
22
+ */
23
+ export function registerIcons(icons: Record<string, IconRegistration>): void {
24
+ for (const [name, reg] of Object.entries(icons)) {
25
+ registry.set(name, toVariants(reg));
26
+ }
27
+ }
28
+
29
+ /** Resolve the component for an icon name, preferring the fill variant when asked. */
30
+ export function getIconComponent(
31
+ name: string,
32
+ fill = false,
33
+ ): Component | undefined {
34
+ const entry = registry.get(name);
35
+ if (!entry) return undefined;
36
+ return fill && entry.fill ? entry.fill : entry.outline;
37
+ }
38
+
39
+ /** Whether an icon name has been registered. */
40
+ export function hasIcon(name: string): boolean {
41
+ return registry.has(name);
42
+ }
43
+
44
+ const warned = new Set<string>();
45
+
46
+ function importSnippet(name: string): string {
47
+ const validIdent = /^[a-zA-Z_$][\w$]*$/.test(name);
48
+ const ident = validIdent ? name : `icon_${name.replace(/[^\w$]/g, "_")}`;
49
+ const reg = validIdent ? `{ ${name} }` : `{ "${name}": ${ident} }`;
50
+ return (
51
+ ` import ${ident} from "@material-symbols/svg-400/outlined/${name}.svg?component";\n` +
52
+ ` import { registerIcons } from "@cfasim-ui/components";\n` +
53
+ ` registerIcons(${reg});`
54
+ );
55
+ }
56
+
57
+ /** Warn once per unregistered icon name with copy-pasteable registration code. */
58
+ export function warnMissingIcon(name: string): void {
59
+ if (warned.has(name)) return;
60
+ warned.add(name);
61
+ console.warn(
62
+ `[cfasim-ui] Icon "${name}" is not registered — nothing will render.\n` +
63
+ `Register it once at startup:\n\n` +
64
+ `${importSnippet(name)}\n\n` +
65
+ `Requires dev deps "@material-symbols/svg-400" + "vite-svg-loader".\n` +
66
+ `Docs: https://cdcgov.github.io/cfa-simulator/docs/cfasim-ui/components/icon#registering-icons`,
67
+ );
68
+ }
@@ -74,15 +74,18 @@ const isMac =
74
74
  /Mac|iPhone|iPad/.test(navigator.platform);
75
75
  const applyShortcut = isMac ? "⌘S" : "Ctrl+S";
76
76
 
77
- // External value updates (e.g., parent Reset) re-seed the editor. The
78
- // inequality check keeps the post-apply re-render a no-op: when the
79
- // parent absorbs the user's apply, `props.value` re-serializes back
80
- // to the same string the user just typed, so we leave `text` untouched.
77
+ // External value updates (e.g., parent Reset) re-seed the editor. We
78
+ // compare against the last value we sourced from the parent — not
79
+ // against the live editor text — so a parent re-render that hands us a
80
+ // fresh object ref with the same contents doesn't clobber whatever the
81
+ // user is currently typing.
82
+ let lastSeeded = text.value;
81
83
  watch(
82
84
  () => props.value,
83
85
  (v) => {
84
86
  const next = serialize(v, format.value);
85
- if (next === text.value) return;
87
+ if (next === lastSeeded) return;
88
+ lastSeeded = next;
86
89
  text.value = next;
87
90
  error.value = "";
88
91
  },
@@ -8,6 +8,9 @@ export { default as Grid } from "./Grid/Grid.vue";
8
8
  export type { GridCols, GridGap } from "./Grid/Grid.vue";
9
9
  export { default as Hint } from "./Hint/Hint.vue";
10
10
  export { default as Icon } from "./Icon/Icon.vue";
11
+ export type { IconSize } from "./Icon/Icon.vue";
12
+ export { registerIcons, hasIcon } from "./Icon/registry";
13
+ export type { IconRegistration, IconVariants } from "./Icon/registry";
11
14
  export { default as LightDarkToggle } from "./LightDarkToggle/LightDarkToggle.vue";
12
15
  export { default as NumberInput } from "./NumberInput/NumberInput.vue";
13
16
  export type { NumberRange } from "./NumberInput/NumberInput.vue";