@pure-ds/storybook 0.1.0

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.
Files changed (129) hide show
  1. package/.storybook/addons/description/preview.js +15 -0
  2. package/.storybook/addons/description/register.js +60 -0
  3. package/.storybook/addons/html-preview/Panel.jsx +327 -0
  4. package/.storybook/addons/html-preview/constants.js +6 -0
  5. package/.storybook/addons/html-preview/preview.js +178 -0
  6. package/.storybook/addons/html-preview/register.js +16 -0
  7. package/.storybook/addons/pds-configurator/SearchTool.js +44 -0
  8. package/.storybook/addons/pds-configurator/Tool.js +30 -0
  9. package/.storybook/addons/pds-configurator/constants.js +9 -0
  10. package/.storybook/addons/pds-configurator/preview.js +159 -0
  11. package/.storybook/addons/pds-configurator/register.js +24 -0
  12. package/.storybook/docs.css +35 -0
  13. package/.storybook/htmlPreview.css +103 -0
  14. package/.storybook/htmlPreview.js +271 -0
  15. package/.storybook/main.js +160 -0
  16. package/.storybook/preview-body.html +48 -0
  17. package/.storybook/preview-head.html +11 -0
  18. package/.storybook/preview.js +1563 -0
  19. package/README.md +266 -0
  20. package/bin/index.js +40 -0
  21. package/dist/pds-reference.json +2101 -0
  22. package/package.json +45 -0
  23. package/pds.config.js +6 -0
  24. package/public/assets/css/app.css +1216 -0
  25. package/public/assets/data/auto-design-advanced.json +704 -0
  26. package/public/assets/data/auto-design-simple.json +123 -0
  27. package/public/assets/img/icon-512x512.png +0 -0
  28. package/public/assets/img/logo-trans.png +0 -0
  29. package/public/assets/img/logo.png +0 -0
  30. package/public/assets/js/app.js +15088 -0
  31. package/public/assets/js/app.js.map +7 -0
  32. package/public/assets/js/lit.js +1176 -0
  33. package/public/assets/js/lit.js.map +7 -0
  34. package/public/assets/js/pds.js +9801 -0
  35. package/public/assets/js/pds.js.map +7 -0
  36. package/public/assets/pds/components/pds-calendar.js +837 -0
  37. package/public/assets/pds/components/pds-drawer.js +857 -0
  38. package/public/assets/pds/components/pds-icon.js +338 -0
  39. package/public/assets/pds/components/pds-jsonform.js +1775 -0
  40. package/public/assets/pds/components/pds-richtext.js +1035 -0
  41. package/public/assets/pds/components/pds-scrollrow.js +331 -0
  42. package/public/assets/pds/components/pds-splitpanel.js +401 -0
  43. package/public/assets/pds/components/pds-tabstrip.js +251 -0
  44. package/public/assets/pds/components/pds-toaster.js +446 -0
  45. package/public/assets/pds/components/pds-upload.js +657 -0
  46. package/public/assets/pds/custom-elements.json +2003 -0
  47. package/public/assets/pds/icons/pds-icons.svg +498 -0
  48. package/public/assets/pds/pds-css-complete.json +1861 -0
  49. package/public/assets/pds/pds-runtime-config.json +11 -0
  50. package/public/assets/pds/pds.css-data.json +2152 -0
  51. package/public/assets/pds/styles/pds-components.css +1944 -0
  52. package/public/assets/pds/styles/pds-components.css.js +3895 -0
  53. package/public/assets/pds/styles/pds-primitives.css +352 -0
  54. package/public/assets/pds/styles/pds-primitives.css.js +711 -0
  55. package/public/assets/pds/styles/pds-styles.css +3761 -0
  56. package/public/assets/pds/styles/pds-styles.css.js +7529 -0
  57. package/public/assets/pds/styles/pds-tokens.css +699 -0
  58. package/public/assets/pds/styles/pds-tokens.css.js +1405 -0
  59. package/public/assets/pds/styles/pds-utilities.css +763 -0
  60. package/public/assets/pds/styles/pds-utilities.css.js +1533 -0
  61. package/public/assets/pds/vscode-custom-data.json +824 -0
  62. package/scripts/build-pds-reference.mjs +807 -0
  63. package/scripts/generate-stories.js +542 -0
  64. package/scripts/package-build.js +86 -0
  65. package/src/js/app.js +17 -0
  66. package/src/js/common/ask.js +208 -0
  67. package/src/js/common/common.js +20 -0
  68. package/src/js/common/font-loader.js +200 -0
  69. package/src/js/common/msg.js +90 -0
  70. package/src/js/lit.js +40 -0
  71. package/src/js/pds-core/pds-config.js +1162 -0
  72. package/src/js/pds-core/pds-enhancer-metadata.js +75 -0
  73. package/src/js/pds-core/pds-enhancers.js +357 -0
  74. package/src/js/pds-core/pds-enums.js +86 -0
  75. package/src/js/pds-core/pds-generator.js +5317 -0
  76. package/src/js/pds-core/pds-ontology.js +256 -0
  77. package/src/js/pds-core/pds-paths.js +109 -0
  78. package/src/js/pds-core/pds-query.js +571 -0
  79. package/src/js/pds-core/pds-registry.js +129 -0
  80. package/src/js/pds-core/pds.d.ts +129 -0
  81. package/src/js/pds.d.ts +408 -0
  82. package/src/js/pds.js +1579 -0
  83. package/src/pds-core/pds-api.js +105 -0
  84. package/stories/GettingStarted.md +96 -0
  85. package/stories/GettingStarted.stories.js +144 -0
  86. package/stories/WhatIsPDS.md +194 -0
  87. package/stories/WhatIsPDS.stories.js +144 -0
  88. package/stories/components/PdsCalendar.stories.js +263 -0
  89. package/stories/components/PdsDrawer.stories.js +623 -0
  90. package/stories/components/PdsIcon.stories.js +78 -0
  91. package/stories/components/PdsJsonform.stories.js +1444 -0
  92. package/stories/components/PdsRichtext.stories.js +367 -0
  93. package/stories/components/PdsScrollrow.stories.js +140 -0
  94. package/stories/components/PdsSplitpanel.stories.js +502 -0
  95. package/stories/components/PdsTabstrip.stories.js +442 -0
  96. package/stories/components/PdsToaster.stories.js +186 -0
  97. package/stories/components/PdsUpload.stories.js +66 -0
  98. package/stories/enhancements/Dropdowns.stories.js +185 -0
  99. package/stories/enhancements/InteractiveStates.stories.js +625 -0
  100. package/stories/enhancements/MeshGradients.stories.js +320 -0
  101. package/stories/enhancements/OpenGroups.stories.js +227 -0
  102. package/stories/enhancements/RangeSliders.stories.js +232 -0
  103. package/stories/enhancements/RequiredFields.stories.js +189 -0
  104. package/stories/enhancements/Toggles.stories.js +167 -0
  105. package/stories/foundations/Colors.stories.js +283 -0
  106. package/stories/foundations/Icons.stories.js +305 -0
  107. package/stories/foundations/SmartSurfaces.stories.js +367 -0
  108. package/stories/foundations/Spacing.stories.js +175 -0
  109. package/stories/foundations/Typography.stories.js +960 -0
  110. package/stories/foundations/ZIndex.stories.js +325 -0
  111. package/stories/patterns/BorderEffects.stories.js +72 -0
  112. package/stories/patterns/Layout.stories.js +99 -0
  113. package/stories/patterns/Utilities.stories.js +107 -0
  114. package/stories/primitives/Accordion.stories.js +359 -0
  115. package/stories/primitives/Alerts.stories.js +64 -0
  116. package/stories/primitives/Badges.stories.js +183 -0
  117. package/stories/primitives/Buttons.stories.js +229 -0
  118. package/stories/primitives/Cards.stories.js +353 -0
  119. package/stories/primitives/FormGroups.stories.js +569 -0
  120. package/stories/primitives/Forms.stories.js +131 -0
  121. package/stories/primitives/Media.stories.js +203 -0
  122. package/stories/primitives/Tables.stories.js +232 -0
  123. package/stories/reference/ReferenceCatalog.stories.js +28 -0
  124. package/stories/reference/reference-catalog.js +413 -0
  125. package/stories/reference/reference-docs.js +302 -0
  126. package/stories/reference/reference-helpers.js +310 -0
  127. package/stories/utilities/GridSystem.stories.js +208 -0
  128. package/stories/utils/PdsAsk.stories.js +420 -0
  129. package/stories/utils/toast-utils.js +148 -0
@@ -0,0 +1,1035 @@
1
+ /**
2
+ * Slack-style rich text field with a semantic output pipeline.
3
+ *
4
+ * @element pds-richtext
5
+ * @formAssociated
6
+ *
7
+ * @attr {string} name - Form field name included with submitted form data
8
+ * @attr {string} placeholder - Placeholder copy displayed when the editor is empty
9
+ * @attr {boolean} disabled - Disables editing, selection, and toolbar interactions
10
+ * @attr {boolean} required - Marks the field as required for native form validation
11
+ * @attr {boolean} submit-on-enter - When present, pressing Enter submits the host form (Shift+Enter inserts a newline)
12
+ * @attr {boolean} spellcheck - Enables native spellcheck inside the editor (default: true)
13
+ * @attr {boolean} toolbar - Toggles the formatting toolbar UI (default: true)
14
+ * @attr {"html"|"markdown"} format - Output format for `value`; Markdown uses Showdown for sanitised HTML
15
+ * @attr {string} value - Initial editor value; kept in sync with the `value` property
16
+ *
17
+ * @property {string} name - Reflective form control name
18
+ * @property {string} placeholder - Placeholder text for the editor surface
19
+ * @property {boolean} disabled - Reflects the disabled state on the host element
20
+ * @property {boolean} required - Mirrors native required semantics
21
+ * @property {boolean} submitOnEnter - Controls form submission when the user presses Enter
22
+ * @property {boolean} spellcheck - Enables or disables native spell checking
23
+ * @property {boolean} toolbar - Shows or hides the inline formatting toolbar
24
+ * @property {"html"|"markdown"} format - Determines whether the element emits HTML or Markdown
25
+ * @property {string} value - Serialised editor contents as HTML (default) or Markdown
26
+ *
27
+ * @fires input - Fired whenever the editor value syncs from user input
28
+ *
29
+ * @example
30
+ * <form onsubmit="event.preventDefault(); console.log(new FormData(this).get('message'))">
31
+ * <pds-richtext name="message" placeholder="Message Steve" submit-on-enter></pds-richtext>
32
+ * <button type="submit">Send</button>
33
+ * </form>
34
+ */
35
+
36
+ // Refactored: remove Lit dependency, implement as plain HTMLElement with Shadow DOM
37
+ // - Adopts PDS layers (primitives, components) for styling tokens
38
+ // - Replaces hardcoded colors with semantic CSS variables
39
+ // - Preserves form-associated behavior and public API
40
+ // - Toolbar & editor logic retained; uses minimal template string assembly
41
+ 4;
42
+ export class RichText extends HTMLElement {
43
+ #internals;
44
+ #editorDiv;
45
+ #converter;
46
+ #loadingShowdown = false;
47
+ #loadedShowdown = false;
48
+ #isSyncingFromEditor = false;
49
+ #isReflectingValue = false;
50
+ #displayValue = "";
51
+ #showdownPromise = null;
52
+ #warnedMarkdownFallback = false;
53
+
54
+ static formAssociated = true;
55
+
56
+ static get observedAttributes() {
57
+ return [
58
+ "name",
59
+ "placeholder",
60
+ "disabled",
61
+ "required",
62
+ "submit-on-enter",
63
+ "spellcheck",
64
+ "toolbar",
65
+ "value",
66
+ "format",
67
+ ];
68
+ }
69
+
70
+ constructor() {
71
+ super();
72
+ this.attachShadow({ mode: "open" });
73
+ this.#internals = this.attachInternals();
74
+ this._submitOnEnter = false;
75
+ this._toolbar = true;
76
+ this._spellcheck = true;
77
+ this._value = ""; // form submission value (HTML or Markdown)
78
+ this._placeholder = "";
79
+ this._disabled = false;
80
+ this._required = false;
81
+ this._format = "html";
82
+ this.#displayValue = "";
83
+ }
84
+
85
+ // Property accessors (reflective where needed)
86
+
87
+ /**
88
+ * Current form field name used when serialising via `FormData`.
89
+ * @returns {string}
90
+ */
91
+ get name() {
92
+ return this.getAttribute("name") || "";
93
+ }
94
+ /**
95
+ * Update the form field name.
96
+ * @param {string|null} v
97
+ */
98
+ set name(v) {
99
+ if (v == null) this.removeAttribute("name");
100
+ else this.setAttribute("name", v);
101
+ }
102
+ /**
103
+ * Placeholder text shown while the editor is empty.
104
+ * @returns {string}
105
+ */
106
+ get placeholder() {
107
+ return this._placeholder;
108
+ }
109
+ /**
110
+ * Set the placeholder text.
111
+ * @param {string|null} v
112
+ */
113
+ set placeholder(v) {
114
+ this._placeholder = v ?? "";
115
+ if (this.#editorDiv)
116
+ this.#editorDiv.setAttribute("data-ph", this._placeholder);
117
+ }
118
+ /**
119
+ * Indicates whether user input is enabled.
120
+ * @returns {boolean}
121
+ */
122
+ get disabled() {
123
+ return this._disabled;
124
+ }
125
+ /**
126
+ * Enable or disable user input.
127
+ * @param {boolean} v
128
+ */
129
+ set disabled(v) {
130
+ const b = !!v;
131
+ this._disabled = b;
132
+ this.toggleAttribute("disabled", b);
133
+ if (this.#editorDiv)
134
+ this.#editorDiv.setAttribute("contenteditable", String(!b));
135
+ }
136
+ /**
137
+ * Whether the control participates in required form validation.
138
+ * @returns {boolean}
139
+ */
140
+ get required() {
141
+ return this._required;
142
+ }
143
+ /**
144
+ * Toggle required validation.
145
+ * @param {boolean} v
146
+ */
147
+ set required(v) {
148
+ const b = !!v;
149
+ this._required = b;
150
+ this.toggleAttribute("required", b);
151
+ }
152
+ /**
153
+ * Submit-on-enter behaviour flag.
154
+ * @returns {boolean}
155
+ */
156
+ get submitOnEnter() {
157
+ return this._submitOnEnter;
158
+ }
159
+ /**
160
+ * Enable or disable submit-on-enter behaviour.
161
+ * @param {boolean} v
162
+ */
163
+ set submitOnEnter(v) {
164
+ const b = !!v;
165
+ this._submitOnEnter = b;
166
+ this.toggleAttribute("submit-on-enter", b);
167
+ }
168
+ /**
169
+ * Indicates if native spell checking is active.
170
+ * @returns {boolean}
171
+ */
172
+ get spellcheck() {
173
+ return this._spellcheck;
174
+ }
175
+ /**
176
+ * Toggle native spell checking support.
177
+ * @param {boolean} v
178
+ */
179
+ set spellcheck(v) {
180
+ const b = !!v;
181
+ this._spellcheck = b;
182
+ this.toggleAttribute("spellcheck", b);
183
+ if (this.#editorDiv) this.#editorDiv.setAttribute("spellcheck", String(b));
184
+ }
185
+ /**
186
+ * Whether the formatting toolbar is rendered.
187
+ * @returns {boolean}
188
+ */
189
+ get toolbar() {
190
+ return this._toolbar;
191
+ }
192
+ /**
193
+ * Show or hide the formatting toolbar.
194
+ * @param {boolean} v
195
+ */
196
+ set toolbar(v) {
197
+ const b = !!v;
198
+ this._toolbar = b;
199
+ this.toggleAttribute("toolbar", b);
200
+ this.#render();
201
+ }
202
+ /**
203
+ * Current output format for the value (HTML or Markdown).
204
+ * @returns {"html"|"markdown"}
205
+ */
206
+ get format() {
207
+ return this._format;
208
+ }
209
+ /**
210
+ * Change the output format for future values.
211
+ * @param {string|null} v
212
+ */
213
+ set format(v) {
214
+ const next =
215
+ (v ?? "").toString().toLowerCase() === "markdown" ? "markdown" : "html";
216
+ if (next === "html") {
217
+ if (this.hasAttribute("format")) this.removeAttribute("format");
218
+ } else if (this.getAttribute("format") !== "markdown") {
219
+ this.setAttribute("format", "markdown");
220
+ }
221
+ }
222
+ /**
223
+ * Serialised editor contents respecting the configured `format`.
224
+ * @returns {string}
225
+ */
226
+ get value() {
227
+ return this._value;
228
+ }
229
+ /**
230
+ * Update the editor value programmatically.
231
+ * @param {string|null} v
232
+ */
233
+ set value(v) {
234
+ const next = v ?? "";
235
+ if (next === this._value) {
236
+ this.#reflectValueAttribute(this._value);
237
+ if (!this.#isSyncingFromEditor) {
238
+ this.#updateEditorFromValue({ reflect: false, updateForm: false });
239
+ }
240
+ return;
241
+ }
242
+ this._value = next;
243
+ if (this.#isSyncingFromEditor) {
244
+ this.#reflectValueAttribute(this._value);
245
+ return;
246
+ }
247
+ this.#updateEditorFromValue();
248
+ }
249
+
250
+ /**
251
+ * Reflect attribute mutations into the corresponding property state.
252
+ * @param {string} name
253
+ * @param {string|null} oldV
254
+ * @param {string|null} newV
255
+ */
256
+ attributeChangedCallback(name, oldV, newV) {
257
+ if (oldV === newV) return;
258
+ switch (name) {
259
+ case "placeholder":
260
+ this.placeholder = newV || "";
261
+ break;
262
+ case "disabled":
263
+ this.disabled = this.hasAttribute("disabled");
264
+ break;
265
+ case "required":
266
+ this.required = this.hasAttribute("required");
267
+ break;
268
+ case "submit-on-enter":
269
+ this.submitOnEnter = this.hasAttribute("submit-on-enter");
270
+ break;
271
+ case "spellcheck":
272
+ this.spellcheck = this.hasAttribute("spellcheck");
273
+ break;
274
+ case "toolbar":
275
+ this.toolbar = this.hasAttribute("toolbar");
276
+ break;
277
+ case "value":
278
+ if (this.#isReflectingValue) break;
279
+ this._value = newV || "";
280
+ if (!this.#isSyncingFromEditor) this.#updateEditorFromValue();
281
+ break;
282
+ case "format":
283
+ this.#applyFormat(newV);
284
+ break;
285
+ }
286
+ }
287
+
288
+ #applyFormat(value) {
289
+ const next =
290
+ (value ?? "").toString().toLowerCase() === "markdown"
291
+ ? "markdown"
292
+ : "html";
293
+ if (next === this._format) return;
294
+ this._format = next;
295
+ const refresh = () => this.#updateEditorFromValue({
296
+ reflect: true,
297
+ updateForm: true,
298
+ forceDisplayRefresh: true,
299
+ });
300
+ if (next === "markdown") {
301
+ this.#ensureShowdown().then(refresh);
302
+ } else {
303
+ refresh();
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Reference to the associated HTMLFormElement, when applicable.
309
+ * @returns {HTMLFormElement|null}
310
+ */
311
+ get form() {
312
+ return this.#internals.form;
313
+ }
314
+ /**
315
+ * Run native form validation against the control.
316
+ * @returns {boolean}
317
+ */
318
+ checkValidity() {
319
+ return this.#internals.checkValidity();
320
+ }
321
+ /**
322
+ * Report validity using the browser's built-in UI.
323
+ * @returns {boolean}
324
+ */
325
+ reportValidity() {
326
+ return this.#internals.reportValidity();
327
+ }
328
+
329
+ /**
330
+ * Attach DOM, adopted styles, and hydrate the initial value on connect.
331
+ * @returns {Promise<void>}
332
+ */
333
+ async connectedCallback() {
334
+ this.#render();
335
+ await this.#adoptStyles();
336
+ if (this._format === "markdown") await this.#ensureShowdown();
337
+ else this.#ensureShowdown();
338
+ this.#updateEditorFromValue({ reflect: true, updateForm: true, forceDisplayRefresh: true });
339
+ }
340
+
341
+ async #adoptStyles() {
342
+ // Component stylesheet (tokens + semantic vars)
343
+ const componentStyles = PDS.createStylesheet(/*css*/ `@layer richtext {
344
+ :host { display:block; color: var(--rt-fg, var(--color-text-primary)); font: var(--font-body-sm, 14px/1.35 system-ui,-apple-system,Segoe UI,Roboto,sans-serif); }
345
+ :host([disabled]) { opacity: .6; pointer-events: none; }
346
+ .box { border: 1px solid var(--rt-border, var(--color-border, currentColor)); border-radius: var(--radius-md,8px); background: var(--rt-bg, var(--color-input-bg)); }
347
+ .box:focus-within {
348
+ border-color: var(--rt-border-focus, var(--color-primary-500, var(--color-primary))); box-shadow: 0 0 0 3px var(--rt-focus-ring, var(--color-focus-ring, color-mix(in oklab, var(--color-primary) 20%, CanvasText 80%)));
349
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-primary-500) 30%, transparent);
350
+
351
+ .ed {
352
+ background-color: var(--color-surface-base);
353
+ }
354
+ }
355
+
356
+ .toolbar {background-color: var(--surface-subtle-bg); display:flex; gap: var(--spacing-2,10px); align-items:center; border-bottom: 1px solid var(--rt-border, var(--color-border-muted)); border-radius: var(--radius-md,8px) var(--radius-md,8px) 0 0; }
357
+ .tbtn { transition: none; display:inline-flex; align-items:center; justify-content:center; width:22px; height:22px; border-radius: var(--radius-sm,6px); cursor:pointer; user-select:none; color: inherit; background: transparent; border:none; }
358
+ .tbtn:hover { background: var(--color-surface-hover, color-mix(in oklab, CanvasText 12%, transparent)); }
359
+ .edwrap { position:relative; }
360
+ .ed { display: block; min-height:90px; max-height: 400px; overflow:auto; padding: var(--spacing-1, 0) var(--spacing-2, 0); outline:none; word-break:break-word; border-radius: 0 0 var(--radius-md,8px) var(--radius-md,8px); background: var(--rt-editor-bg, var(--color-input-bg)); }
361
+ .ed[contenteditable="true"]:empty::before { content: attr(data-ph); color: var(--rt-muted, var(--color-text-muted)); pointer-events:none; }
362
+ .send { margin-left:auto; display:inline-flex; gap: var(--spacing-2,8px); align-items:center; }
363
+ button.icon { background:transparent; border:0; color:inherit; cursor:pointer; padding:6px; border-radius: var(--radius-sm,6px); }
364
+ button.icon:hover { background: var(--rt-hover-bg, color-mix(in oklab, CanvasText 12%, transparent)); }
365
+ }`);
366
+ try {
367
+ await PDS.adoptLayers(
368
+ this.shadowRoot,
369
+ ["primitives", "components"],
370
+ [componentStyles]
371
+ );
372
+ } catch (e) {
373
+ console.warn("richtext: adoptLayers failed", e);
374
+ }
375
+ }
376
+
377
+ async #ensureShowdown() {
378
+ if (this.#loadedShowdown) return true;
379
+ if (this.#showdownPromise) return this.#showdownPromise;
380
+
381
+ this.#loadingShowdown = true;
382
+ this.#showdownPromise = (async () => {
383
+ try {
384
+ let showdownExports = await this.#importShowdownFromMap();
385
+
386
+ if (!showdownExports) {
387
+ await this.#loadShowdownFromCdn();
388
+ showdownExports = window.showdown;
389
+ }
390
+
391
+ const showdownApi = this.#resolveShowdownExports(showdownExports);
392
+ if (!showdownApi) {
393
+ throw new Error("Showdown exports missing Converter");
394
+ }
395
+
396
+ this.#applyShowdownConverter(showdownApi);
397
+ this.#loadedShowdown = true;
398
+ this.#warnedMarkdownFallback = false;
399
+ } catch (e) {
400
+ console.warn(
401
+ "Showdown failed to load; cleaning will still happen via local sanitizer.",
402
+ e
403
+ );
404
+ this.#loadedShowdown = false;
405
+ } finally {
406
+ this.#loadingShowdown = false;
407
+ if (!this.#loadedShowdown) this.#showdownPromise = null;
408
+ }
409
+ return this.#loadedShowdown;
410
+ })();
411
+
412
+ return this.#showdownPromise;
413
+ }
414
+
415
+ async #importShowdownFromMap() {
416
+ try {
417
+ const mod = await import("#showdown");
418
+ return mod;
419
+ } catch (err) {
420
+ return null;
421
+ }
422
+ }
423
+
424
+ async #loadShowdownFromCdn() {
425
+ if ("showdown" in window) return;
426
+ try {
427
+ await this.#loadScript(
428
+ "https://cdn.jsdelivr.net/npm/showdown@2.1.0/dist/showdown.min.js"
429
+ );
430
+ } catch {
431
+ await this.#loadScript(
432
+ "https://cdnjs.cloudflare.com/ajax/libs/showdown/2.1.0/showdown.min.js"
433
+ );
434
+ }
435
+ }
436
+
437
+ #resolveShowdownExports(exports) {
438
+ const candidates = [
439
+ exports,
440
+ exports && exports.default,
441
+ exports && exports.showdown,
442
+ exports && exports.default && exports.default.showdown,
443
+ ];
444
+ for (const candidate of candidates) {
445
+ if (candidate && typeof candidate.Converter === "function") {
446
+ return candidate;
447
+ }
448
+ }
449
+ if (exports && typeof exports.Converter === "function") return exports;
450
+ return null;
451
+ }
452
+
453
+ #applyShowdownConverter(api) {
454
+ if (!api || typeof api.Converter !== "function") {
455
+ throw new Error("Invalid Showdown API");
456
+ }
457
+ if (!window.showdown) {
458
+ window.showdown = api;
459
+ }
460
+ // @ts-ignore
461
+ this.#converter = new api.Converter({
462
+ simplifiedAutoLink: true,
463
+ openLinksInNewWindow: true,
464
+ strikethrough: true,
465
+ emoji: false,
466
+ ghMentions: false,
467
+ tables: false,
468
+ });
469
+ }
470
+
471
+ // Load a classic script tag; resolves on load, rejects on error
472
+ #loadScript(src) {
473
+ return new Promise((resolve, reject) => {
474
+ const s = document.createElement("script");
475
+ s.src = src;
476
+ s.async = true;
477
+ s.onload = () => resolve(true);
478
+ s.onerror = () => reject(new Error(`Failed to load script ${src}`));
479
+ document.head.appendChild(s);
480
+ });
481
+ }
482
+
483
+ #render() {
484
+ if (!this.shadowRoot) return;
485
+ const _labels = ["B", "I", "</>", "•", "1."]; // "🔗", "S"
486
+ const toolbarHtml = this._toolbar
487
+ ? `<div class="toolbar">${_labels
488
+ .map(
489
+ (l) =>
490
+ `<button class="tbtn btn-btn-sm" data-tool="${l}">${
491
+ l === "</>" ? "&lt;/&gt;" : l
492
+ }</button>`
493
+ )
494
+ .join("")}</div>`
495
+ : "";
496
+ this.shadowRoot.innerHTML = `
497
+ <div class="box">
498
+ ${toolbarHtml}
499
+ <div class="edwrap">
500
+ <div class="ed" role="textbox" aria-multiline="true" data-ph="${
501
+ this._placeholder
502
+ }" contenteditable="${!this._disabled}" spellcheck="${
503
+ this._spellcheck
504
+ }">${this.#displayValue || ""}</div>
505
+ </div>
506
+ </div>`;
507
+ this.#editorDiv = this.shadowRoot.querySelector(".ed");
508
+ // Wire editor events
509
+ this.#editorDiv.addEventListener("paste", this.#onPaste);
510
+ this.#editorDiv.addEventListener("keydown", this.#onKeyDown);
511
+ this.#editorDiv.addEventListener("input", this.#onInput);
512
+ // Toolbar buttons
513
+ this.shadowRoot.querySelectorAll(".tbtn").forEach((btn) => {
514
+ btn.addEventListener("click", () => {
515
+ this.#focus();
516
+ const label = btn.getAttribute("data-tool");
517
+ this.#executeTool(label);
518
+ this.#syncValue();
519
+ });
520
+ btn.addEventListener("mousedown", (e) => e.preventDefault());
521
+ });
522
+ if (this.#editorDiv && this.#editorDiv.innerHTML !== this.#displayValue) {
523
+ this.#editorDiv.innerHTML = this.#displayValue;
524
+ }
525
+ }
526
+
527
+ #executeTool(label) {
528
+ switch (label) {
529
+ case "B":
530
+ document.execCommand("bold");
531
+ break;
532
+ case "I":
533
+ document.execCommand("italic");
534
+ break;
535
+ case "S":
536
+ document.execCommand("strikeThrough");
537
+ break;
538
+ case "🔗":
539
+ this.#makeLink();
540
+ break;
541
+ case "</>":
542
+ this.#toggleCode();
543
+ break;
544
+ case "⤴":
545
+ this.#insertBlock("blockquote");
546
+ break;
547
+ case "•":
548
+ this.#insertBlock("ul");
549
+ break;
550
+ case "1.":
551
+ this.#insertBlock("ol");
552
+ break;
553
+ }
554
+ }
555
+
556
+ #focus() {
557
+ this.#editorDiv?.focus();
558
+ }
559
+
560
+ // Input -> update cleaned value for form submission
561
+ #onInput = () => {
562
+ this.#syncValue();
563
+ };
564
+
565
+ // Paste as plain text
566
+ #onPaste = (e) => {
567
+ e.preventDefault();
568
+ const text = (e.clipboardData || window.clipboardData).getData(
569
+ "text/plain"
570
+ );
571
+ document.execCommand("insertText", false, text);
572
+ };
573
+
574
+ #onKeyDown = async (e) => {
575
+ if (e.key === "Enter" && !e.isComposing) {
576
+ if (e.shiftKey || !this._submitOnEnter) return; // newline allowed
577
+ e.preventDefault();
578
+ await this.#syncValue();
579
+ this.#requestSubmit();
580
+ }
581
+ };
582
+
583
+ // ===== Formatting helpers =====
584
+ #makeLink() {
585
+ const url = window.prompt("Link URL", "https://");
586
+ if (!url) return;
587
+ document.execCommand("createLink", false, url);
588
+ }
589
+
590
+ #toggleCode() {
591
+ // Wrap selection with <code> or unwrap if already in code
592
+ const sel = window.getSelection();
593
+ if (!sel || sel.rangeCount === 0) return;
594
+ const range = sel.getRangeAt(0);
595
+ // Simple toggle: if ancestor <code>, unwrap; else wrap
596
+ const codeAncestor = this.#closestAncestor(
597
+ range.commonAncestorContainer,
598
+ "CODE"
599
+ );
600
+ if (codeAncestor) {
601
+ const parent = codeAncestor.parentNode;
602
+ while (codeAncestor.firstChild)
603
+ parent.insertBefore(codeAncestor.firstChild, codeAncestor);
604
+ parent.removeChild(codeAncestor);
605
+ } else {
606
+ const wrapper = document.createElement("code");
607
+ range.surroundContents(wrapper);
608
+ }
609
+ }
610
+
611
+ #insertBlock(type) {
612
+ if (type === "blockquote") {
613
+ document.execCommand("formatBlock", false, "blockquote");
614
+ return;
615
+ }
616
+ if (type === "ul") {
617
+ document.execCommand("insertUnorderedList");
618
+ return;
619
+ }
620
+ if (type === "ol") {
621
+ document.execCommand("insertOrderedList");
622
+ return;
623
+ }
624
+ }
625
+
626
+ #closestAncestor(node, tag) {
627
+ while (node) {
628
+ if (node.nodeType === 1 && node.tagName === tag) return node;
629
+ node = node.parentNode;
630
+ }
631
+ return null;
632
+ }
633
+
634
+ async #syncValue() {
635
+ if (!this.#editorDiv) return;
636
+ if (this._format === "markdown") await this.#ensureShowdown();
637
+ const { html, markdown } = this.#prepareContentFromEditor(
638
+ this.#editorDiv.innerHTML || "",
639
+ this._format === "markdown"
640
+ );
641
+ const nextValue = this._format === "markdown" ? markdown : html;
642
+ this.#isSyncingFromEditor = true;
643
+ this.value = nextValue;
644
+ this.#isSyncingFromEditor = false;
645
+ this.#displayValue = html;
646
+ if (this.#editorDiv.innerHTML !== html) {
647
+ this.#editorDiv.innerHTML = html;
648
+ }
649
+ this.#internals.setFormValue(nextValue);
650
+ this.#updateValidityState(html, nextValue);
651
+ this.dispatchEvent(
652
+ new InputEvent("input", { bubbles: true, composed: true })
653
+ );
654
+ }
655
+
656
+ #canonicalize(html) {
657
+ // normalize newlines and div/paragraphs
658
+ const tmp = document.createElement("div");
659
+ tmp.innerHTML = html;
660
+ // remove disallowed attributes
661
+ tmp.querySelectorAll("*").forEach((el) => {
662
+ [...el.attributes].forEach((a) => {
663
+ if (!/^href$/i.test(a.name)) el.removeAttribute(a.name);
664
+ });
665
+ });
666
+ return tmp.innerHTML;
667
+ }
668
+
669
+ #sanitizeHtml(html) {
670
+ const canonical = this.#canonicalize(html || "");
671
+ if (!canonical) return "";
672
+ const scratch = document.createElement("div");
673
+ scratch.innerHTML = canonical;
674
+ if (!(scratch.textContent || "").trim()) return "";
675
+ this.#stripDisallowedElements(scratch);
676
+ this.#normalizeBlockContainers(scratch);
677
+ this.#removeEmptyParagraphs(scratch);
678
+ this.#normalizeAnchors(scratch);
679
+ return scratch.innerHTML;
680
+ }
681
+
682
+ #stripDisallowedElements(root) {
683
+ const allowed = new Set([
684
+ "A",
685
+ "B",
686
+ "BLOCKQUOTE",
687
+ "BR",
688
+ "CODE",
689
+ "DIV",
690
+ "EM",
691
+ "H1",
692
+ "H2",
693
+ "H3",
694
+ "H4",
695
+ "H5",
696
+ "H6",
697
+ "I",
698
+ "LI",
699
+ "OL",
700
+ "P",
701
+ "PRE",
702
+ "S",
703
+ "STRONG",
704
+ "U",
705
+ "UL",
706
+ ]);
707
+ root.querySelectorAll("*").forEach((el) => {
708
+ const tag = el.tagName.toUpperCase();
709
+ if (!allowed.has(tag)) {
710
+ const frag = document.createDocumentFragment();
711
+ while (el.firstChild) frag.appendChild(el.firstChild);
712
+ el.replaceWith(frag);
713
+ return;
714
+ }
715
+ if (tag === "A") {
716
+ [...el.attributes].forEach((attr) => {
717
+ if (!/^(href|target|rel)$/i.test(attr.name)) {
718
+ el.removeAttribute(attr.name);
719
+ }
720
+ });
721
+ } else {
722
+ [...el.attributes].forEach((attr) => {
723
+ el.removeAttribute(attr.name);
724
+ });
725
+ }
726
+ });
727
+ }
728
+
729
+ #normalizeBlockContainers(root) {
730
+ const blockChildren = new Set([
731
+ "BLOCKQUOTE",
732
+ "DIV",
733
+ "H1",
734
+ "H2",
735
+ "H3",
736
+ "H4",
737
+ "H5",
738
+ "H6",
739
+ "OL",
740
+ "PRE",
741
+ "UL",
742
+ ]);
743
+ root.querySelectorAll("p").forEach((p) => {
744
+ const hasBlockChild = [...p.children].some((child) =>
745
+ blockChildren.has(child.tagName.toUpperCase())
746
+ );
747
+ if (!hasBlockChild) return;
748
+ const parent = p.parentNode;
749
+ if (!parent) return;
750
+ while (p.firstChild) parent.insertBefore(p.firstChild, p);
751
+ parent.removeChild(p);
752
+ });
753
+ }
754
+
755
+ #removeEmptyParagraphs(root) {
756
+ root.querySelectorAll("p").forEach((p) => {
757
+ const hasContent = (p.textContent || "").replace(/\u00A0/g, " ").trim().length > 0;
758
+ const hasNonBreakChild = [...p.children].some((child) => child.tagName && child.tagName.toUpperCase() !== "BR");
759
+ if (!hasContent && !hasNonBreakChild) {
760
+ p.remove();
761
+ }
762
+ });
763
+ }
764
+
765
+ #normalizeAnchors(root) {
766
+ root.querySelectorAll("a").forEach((a) => {
767
+ const href = a.getAttribute("href") || "";
768
+ if (!href || /^javascript:/i.test(href)) {
769
+ a.removeAttribute("href");
770
+ a.removeAttribute("target");
771
+ a.removeAttribute("rel");
772
+ return;
773
+ }
774
+ if (!a.hasAttribute("target")) {
775
+ a.setAttribute("target", "_blank");
776
+ }
777
+ const existingRel = (a.getAttribute("rel") || "")
778
+ .split(/\s+/)
779
+ .filter(Boolean);
780
+ if (!existingRel.includes("noopener")) existingRel.push("noopener");
781
+ if (!existingRel.includes("noreferrer")) existingRel.push("noreferrer");
782
+ a.setAttribute("rel", existingRel.join(" "));
783
+ });
784
+ }
785
+
786
+ #cleanHtml(html, options = {}) {
787
+ const { roundtrip = true } = options;
788
+ const sanitized = this.#sanitizeHtml(html);
789
+ if (!sanitized) return "";
790
+ if (!roundtrip) return sanitized;
791
+ const md = this.#htmlToMinimalMarkdown(sanitized);
792
+ if (!md.trim()) return "";
793
+ return this.#loadedShowdown && this.#converter
794
+ ? this.#converter.makeHtml(md)
795
+ : this.#markdownToBareHtml(md);
796
+ }
797
+
798
+ #prepareContentFromEditor(html, requireConverter = false) {
799
+ const cleanedHtml = this.#cleanHtml(html || "", { roundtrip: false });
800
+ if (!cleanedHtml) return { html: "", markdown: "" };
801
+ if (this.#converter && typeof this.#converter.makeMarkdown === "function") {
802
+ return { html: cleanedHtml, markdown: this.#converter.makeMarkdown(cleanedHtml) };
803
+ }
804
+ if (requireConverter && !this.#warnedMarkdownFallback) {
805
+ console.warn(
806
+ "pds-richtext: Showdown converter unavailable; falling back to internal markdown cleaner."
807
+ );
808
+ this.#warnedMarkdownFallback = true;
809
+ }
810
+ return {
811
+ html: cleanedHtml,
812
+ markdown: this.#htmlToMinimalMarkdown(cleanedHtml),
813
+ };
814
+ }
815
+
816
+ #updateValidityState(displayHtml, formValue) {
817
+ if (!this.required) {
818
+ this.#internals.setValidity({}, "", this.#editorDiv);
819
+ return;
820
+ }
821
+ const hasDisplayContent = this.#hasContent(displayHtml);
822
+ const hasValueContent = typeof formValue === "string" && formValue.trim().length > 0;
823
+ if (hasDisplayContent || hasValueContent) {
824
+ this.#internals.setValidity({}, "", this.#editorDiv);
825
+ } else {
826
+ this.#internals.setValidity(
827
+ { customError: true },
828
+ "Please enter a message.",
829
+ this.#editorDiv
830
+ );
831
+ }
832
+ }
833
+
834
+ #hasContent(html) {
835
+ if (!html) return false;
836
+ const tmp = document.createElement("div");
837
+ tmp.innerHTML = html;
838
+ return !!(tmp.textContent || "").trim();
839
+ }
840
+
841
+ #updateEditorFromValue(options = {}) {
842
+ if (
843
+ this._format === "markdown" &&
844
+ (!this.#converter || !this.#loadedShowdown)
845
+ ) {
846
+ this.#ensureShowdown().then(() => this.#applyValueToEditor(options));
847
+ return;
848
+ }
849
+ this.#applyValueToEditor(options);
850
+ }
851
+
852
+ #applyValueToEditor(options = {}) {
853
+ const { reflect = true, updateForm = true, forceDisplayRefresh = false } = options;
854
+ const format = this._format;
855
+ let cleanedHtml;
856
+ if (format === "html") {
857
+ cleanedHtml = this.#cleanHtml(this._value, { roundtrip: false });
858
+ if (cleanedHtml !== this._value) {
859
+ this._value = cleanedHtml;
860
+ }
861
+ } else {
862
+ const renderedHtml = this.#markdownToDisplayHtml(this._value);
863
+ cleanedHtml = this.#cleanHtml(renderedHtml, { roundtrip: false });
864
+ if (this.#converter && typeof this.#converter.makeMarkdown === "function") {
865
+ const normalizedMarkdown = cleanedHtml
866
+ ? this.#converter.makeMarkdown(cleanedHtml)
867
+ : "";
868
+ if (normalizedMarkdown !== this._value) {
869
+ this._value = normalizedMarkdown;
870
+ }
871
+ } else {
872
+ if (!this.#warnedMarkdownFallback) {
873
+ console.warn(
874
+ "pds-richtext: Showdown converter unavailable while normalizing markdown value; using internal sanitizer instead."
875
+ );
876
+ this.#warnedMarkdownFallback = true;
877
+ }
878
+ this._value = cleanedHtml
879
+ ? this.#htmlToMinimalMarkdown(cleanedHtml)
880
+ : "";
881
+ }
882
+ }
883
+
884
+ this.#displayValue = cleanedHtml;
885
+
886
+ if (reflect) this.#reflectValueAttribute(this._value);
887
+
888
+ if (
889
+ this.#editorDiv &&
890
+ (forceDisplayRefresh || this.#editorDiv.innerHTML !== cleanedHtml)
891
+ ) {
892
+ this.#editorDiv.innerHTML = cleanedHtml;
893
+ }
894
+
895
+ if (updateForm) {
896
+ this.#internals.setFormValue(this._value);
897
+ this.#updateValidityState(cleanedHtml, this._value);
898
+ }
899
+ }
900
+
901
+ #markdownToDisplayHtml(md) {
902
+ const value = (md ?? "").toString();
903
+ if (!value.trim()) return "";
904
+ if (this.#converter && typeof this.#converter.makeHtml === "function") {
905
+ return this.#converter.makeHtml(value);
906
+ }
907
+ return this.#markdownToBareHtml(value);
908
+ }
909
+
910
+ #reflectValueAttribute(value) {
911
+ this.#isReflectingValue = true;
912
+ if (value) {
913
+ if (this.getAttribute("value") !== value) {
914
+ this.setAttribute("value", value);
915
+ }
916
+ } else if (this.hasAttribute("value")) {
917
+ this.removeAttribute("value");
918
+ }
919
+ this.#isReflectingValue = false;
920
+ }
921
+
922
+ // Very small HTML -> Markdown covering the toolbar features and basic blocks
923
+ #htmlToMinimalMarkdown(html) {
924
+ const root = document.createElement("div");
925
+ root.innerHTML = html;
926
+ const walk = (n) => {
927
+ if (n.nodeType === 3) return n.nodeValue.replace(/\u00A0/g, " "); // nbsp -> space
928
+ if (n.nodeType !== 1) return "";
929
+ const tag = n.tagName;
930
+ const ch = [...n.childNodes].map(walk).join("");
931
+ switch (tag) {
932
+ case "B":
933
+ case "STRONG":
934
+ return ch ? `**${ch}**` : "";
935
+ case "I":
936
+ case "EM":
937
+ return ch ? `_${ch}_` : "";
938
+ case "S":
939
+ case "STRIKE":
940
+ return ch ? `~~${ch}~~` : "";
941
+ case "CODE":
942
+ return ch ? `\`${ch}\`` : "";
943
+ case "A": {
944
+ const href = n.getAttribute("href") || "";
945
+ const text = ch || href;
946
+ return href ? `[${text}](${href})` : text;
947
+ }
948
+ case "BR":
949
+ return " \n";
950
+ case "DIV":
951
+ case "P":
952
+ return ch ? `${ch}\n\n` : "";
953
+ case "UL":
954
+ return (
955
+ [...n.children].map((li) => `- ${walk(li)}`).join("\n") + "\n\n"
956
+ );
957
+ case "OL": {
958
+ return (
959
+ [...n.children].map((li, i) => `${i + 1}. ${walk(li)}`).join("\n") +
960
+ "\n\n"
961
+ );
962
+ }
963
+ case "LI":
964
+ return ch.replace(/\n+/g, " ");
965
+ case "BLOCKQUOTE":
966
+ return (
967
+ ch
968
+ .split(/\n/)
969
+ .map((l) => (l ? `> ${l}` : ">"))
970
+ .join("\n") + "\n\n"
971
+ );
972
+ default:
973
+ return ch; // drop other tags
974
+ }
975
+ };
976
+ let md = [...root.childNodes].map(walk).join("");
977
+ // collapse excessive blank lines
978
+ md = md.replace(/\n{3,}/g, "\n\n").trim();
979
+ return md;
980
+ }
981
+
982
+ // Fallback Markdown -> bare HTML if Showdown not available
983
+ #markdownToBareHtml(md) {
984
+ // extremely small subset renderer
985
+ let h = md
986
+ .replace(/&/g, "&amp;")
987
+ .replace(/</g, "&lt;")
988
+ .replace(/>/g, "&gt;");
989
+ h = h
990
+ .replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
991
+ .replace(/_(.*?)_/g, "<em>$1</em>")
992
+ .replace(/~~(.*?)~~/g, "<s>$1</s>")
993
+ .replace(/`([^`]+)`/g, "<code>$1</code>")
994
+ .replace(
995
+ /\[(.*?)\]\((https?:[^\s)]+)\)/g,
996
+ '<a href="$2" target="_blank" rel="noopener">$1</a>'
997
+ );
998
+ // Lists & blockquotes (simple, single‑level)
999
+ h = h.replace(/(^|\n)> (.*)/g, "$1<blockquote>$2</blockquote>");
1000
+ // ordered list
1001
+ h = h.replace(
1002
+ /(?:^|\n)(\d+)\. (.*)(?=\n|$)/g,
1003
+ (m, n, txt) => `\n<ol><li>${txt}</li></ol>`
1004
+ );
1005
+ // unordered list
1006
+ h = h.replace(/(?:^|\n)- (.*)(?=\n|$)/g, "\n<ul><li>$1</li></ul>");
1007
+ // paragraphs
1008
+ h = h
1009
+ .split(/\n{2,}/)
1010
+ .map((p) => `<p>${p.replace(/\n/g, "<br>")}</p>`)
1011
+ .join("");
1012
+ return h;
1013
+ }
1014
+
1015
+ // Submit helpers
1016
+ #requestSubmit() {
1017
+ const ev = new CustomEvent("submit-request", {
1018
+ bubbles: true,
1019
+ composed: true,
1020
+ cancelable: true,
1021
+ });
1022
+ if (!this.dispatchEvent(ev)) return;
1023
+ const form = this.form || this.closest("form");
1024
+ if (form) {
1025
+ if (!this.value && this.required) {
1026
+ this.reportValidity();
1027
+ return;
1028
+ }
1029
+ if (typeof form.requestSubmit === "function") form.requestSubmit();
1030
+ else form.dispatchEvent(new Event("submit", { cancelable: true }));
1031
+ }
1032
+ }
1033
+ }
1034
+
1035
+ customElements.define("pds-richtext", RichText);