@shift-software/survey-web-component 0.0.0-stage → 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.
@@ -0,0 +1 @@
1
+ //# sourceMappingURL=library-fonts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"library-fonts.d.ts","sourceRoot":"","sources":["../src/library-fonts.ts"],"names":[],"mappings":""}
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "@shift-software/survey-web-component",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Custom-element wrapper around @shift-software/survey-renderer. Exposes <shift-survey> so non-React hosts (Blazor live-preview, plain HTML embeds, Stencil apps) can mount a survey without pulling React into the host. Ships React + renderer inside the bundle.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "src",
17
+ "README.md"
18
+ ],
19
+ "license": "Apache-2.0",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/ShiftSoftware/ADP",
23
+ "directory": "ADP.Surveys/renderer/packages/survey-web-component"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "scripts": {
29
+ "build": "vite build && tsc -p tsconfig.build.json --emitDeclarationOnly",
30
+ "typecheck": "tsc --noEmit",
31
+ "test": "vitest run",
32
+ "test.watch": "vitest"
33
+ },
34
+ "dependencies": {
35
+ "@shift-software/survey-renderer": "^0.1.0",
36
+ "@shift-software/survey-sdk": "^0.1.0",
37
+ "react": "^19.0.0",
38
+ "react-dom": "^19.0.0"
39
+ },
40
+ "devDependencies": {
41
+ "@testing-library/dom": "^10.4.0",
42
+ "@types/react": "^19.0.0",
43
+ "@types/react-dom": "^19.0.0",
44
+ "jsdom": "^25.0.1",
45
+ "typescript": "^5.6.3",
46
+ "vite": "^6.0.3",
47
+ "vitest": "^2.1.8"
48
+ }
49
+ }
@@ -0,0 +1,273 @@
1
+ import { createElement } from 'react';
2
+ import { createRoot, type Root } from 'react-dom/client';
3
+ import { SurveyRenderer } from '@shift-software/survey-renderer';
4
+ // Relative path on purpose — the renderer's `exports` map is strict and doesn't
5
+ // whitelist `?inline`; reaching through the workspace path is the most robust
6
+ // way to inline the stylesheet at build time.
7
+ import rendererStyles from '../../survey-renderer/src/styles.css?inline';
8
+ import {
9
+ SurveyClient,
10
+ type Survey,
11
+ type SurveySubmission,
12
+ } from '@shift-software/survey-sdk';
13
+
14
+ /**
15
+ * `<shift-survey>` — custom-element wrapper around `<SurveyRenderer>`.
16
+ *
17
+ * Two usage modes, pick whichever fits the host:
18
+ *
19
+ * 1. **API mode** (attribute-driven): the element fetches schema + submits
20
+ * responses against the Surveys API on its own.
21
+ * ```html
22
+ * <shift-survey
23
+ * instance-id="abc-123-guid"
24
+ * api-base="https://api.example.com/api/Surveys"
25
+ * locale="ar"></shift-survey>
26
+ * ```
27
+ *
28
+ * 2. **Schema mode** (property-driven): the host provides the schema + onSubmit
29
+ * directly as DOM properties. Used by the Blazor builder's live-preview
30
+ * (Phase 4) — the host pushes draft schemas via JS interop as the author
31
+ * types.
32
+ * ```ts
33
+ * const el = document.querySelector('shift-survey');
34
+ * el.schema = draftSchema;
35
+ * el.onSubmit = (submission) => { ... };
36
+ * ```
37
+ *
38
+ * Lifecycle events bubble up as CustomEvents on the element (`survey:loaded`,
39
+ * `survey:screen-changed`, `survey:completed`, `survey:error`) — same vocabulary
40
+ * as the postMessage protocol, but dispatched as DOM events for page-level hosts.
41
+ * If the element also sits inside an iframe, postMessage emission still fires
42
+ * for the iframe parent; the two channels coexist.
43
+ */
44
+ export class ShiftSurveyElement extends HTMLElement {
45
+ static get observedAttributes(): string[] {
46
+ return ['instance-id', 'api-base', 'locale', 'mode', 'active-screen-id', 'locale-picker'];
47
+ }
48
+
49
+ /** Schema-mode setter. Assigning this swaps the element into schema mode and
50
+ * re-renders with the new schema immediately. */
51
+ #schema: Survey | null = null;
52
+ /** Schema-mode submit handler. In API mode the element manages this itself. */
53
+ #onSubmit: ((submission: SurveySubmission) => void | Promise<void>) | null = null;
54
+
55
+ #root: Root | null = null;
56
+ #mount: HTMLDivElement | null = null;
57
+ #apiSchema: Survey | null = null;
58
+ #apiError: Error | null = null;
59
+
60
+ constructor() {
61
+ super();
62
+ this.attachShadow({ mode: 'open' });
63
+ }
64
+
65
+ // ─── Lifecycle ───────────────────────────────────────────────────────────
66
+
67
+ connectedCallback(): void {
68
+ if (!this.shadowRoot) return;
69
+ // Inject the renderer's stylesheet once. Shadow DOM blocks the host page's
70
+ // CSS from leaking in — we own our visual layer end-to-end.
71
+ if (!this.shadowRoot.querySelector('style[data-shift-survey]')) {
72
+ const style = document.createElement('style');
73
+ style.setAttribute('data-shift-survey', '');
74
+ style.textContent = rendererStyles;
75
+ this.shadowRoot.appendChild(style);
76
+ }
77
+ if (!this.#mount) {
78
+ this.#mount = document.createElement('div');
79
+ this.#mount.className = 'shift-survey-mount';
80
+ this.shadowRoot.appendChild(this.#mount);
81
+ }
82
+ if (!this.#root) this.#root = createRoot(this.#mount);
83
+ this.#renderIfReady();
84
+ this.#maybeFetchSchema();
85
+ }
86
+
87
+ disconnectedCallback(): void {
88
+ // Defer the unmount — disconnectedCallback fires synchronously during DOM
89
+ // moves, and React 19 complains if we tear down a root mid-render. A
90
+ // microtask delay plus a re-check gives React room to settle. Guard against
91
+ // test teardown races where `window` has been torn down by jsdom.
92
+ queueMicrotask(() => {
93
+ if (this.isConnected || typeof window === 'undefined') return;
94
+ try {
95
+ this.#root?.unmount();
96
+ } catch {
97
+ // jsdom tears down mid-unmount during test runs — no-op is safe.
98
+ }
99
+ this.#root = null;
100
+ });
101
+ }
102
+
103
+ attributeChangedCallback(name: string, oldVal: string | null, newVal: string | null): void {
104
+ if (oldVal === newVal) return;
105
+ if (name === 'instance-id' || name === 'api-base') {
106
+ // API-mode inputs changed — drop any cached schema and refetch.
107
+ this.#apiSchema = null;
108
+ this.#apiError = null;
109
+ this.#maybeFetchSchema();
110
+ }
111
+ this.#renderIfReady();
112
+ }
113
+
114
+ // ─── Properties ──────────────────────────────────────────────────────────
115
+
116
+ get schema(): Survey | null {
117
+ return this.#schema;
118
+ }
119
+ set schema(value: Survey | null) {
120
+ this.#schema = value;
121
+ this.#renderIfReady();
122
+ }
123
+
124
+ get onSubmit(): ((submission: SurveySubmission) => void | Promise<void>) | null {
125
+ return this.#onSubmit;
126
+ }
127
+ set onSubmit(value: ((submission: SurveySubmission) => void | Promise<void>) | null) {
128
+ this.#onSubmit = value;
129
+ this.#renderIfReady();
130
+ }
131
+
132
+ /** Builder-preview jump target. Assigning a screen id makes the renderer
133
+ * jump to that screen (answers preserved); the user can navigate freely
134
+ * afterwards. Mirrors the `active-screen-id` attribute; the property wins
135
+ * when both are set. */
136
+ #activeScreenId: string | null = null;
137
+ get activeScreenId(): string | null {
138
+ return this.#activeScreenId ?? this.getAttribute('active-screen-id');
139
+ }
140
+ set activeScreenId(value: string | null) {
141
+ this.#activeScreenId = value;
142
+ this.#renderIfReady();
143
+ }
144
+
145
+ /** Bump to re-issue a jump to the screen already set on {@link activeScreenId}.
146
+ * Property-only (no attribute) — it is a transient signal, not page state. */
147
+ #activeScreenJumpToken = 0;
148
+ get activeScreenJumpToken(): number {
149
+ return this.#activeScreenJumpToken;
150
+ }
151
+ set activeScreenJumpToken(value: number) {
152
+ this.#activeScreenJumpToken = value;
153
+ this.#renderIfReady();
154
+ }
155
+
156
+ // ─── Internals ───────────────────────────────────────────────────────────
157
+
158
+ #maybeFetchSchema(): void {
159
+ // Only fetch if we're in API mode (instance-id set) AND not already served
160
+ // via the schema property.
161
+ if (this.#schema) return;
162
+ const instanceId = this.getAttribute('instance-id');
163
+ if (!instanceId) return;
164
+ const apiBase = this.getAttribute('api-base');
165
+ if (!apiBase) return;
166
+ const client = new SurveyClient({ baseUrl: apiBase });
167
+ client
168
+ .fetchSchema(instanceId)
169
+ .then((schema) => {
170
+ this.#apiSchema = schema;
171
+ this.#renderIfReady();
172
+ })
173
+ .catch((error: Error) => {
174
+ this.#apiError = error;
175
+ this.#dispatch('survey:error', { message: error.message });
176
+ this.#renderIfReady();
177
+ });
178
+ }
179
+
180
+ #renderIfReady(): void {
181
+ if (!this.#root) return;
182
+
183
+ const apiBase = this.getAttribute('api-base');
184
+ const instanceId = this.getAttribute('instance-id');
185
+ const locale = this.getAttribute('locale') ?? undefined;
186
+ const agentMode = this.getAttribute('mode') === 'agent';
187
+ // `locale-picker` is an override, not a switch: absent means "let the renderer
188
+ // decide" (shown when the survey declares more than one locale), so an existing
189
+ // host that sets nothing gets the picker without changing its markup.
190
+ const localePickerAttr = this.getAttribute('locale-picker');
191
+ const showLocalePicker =
192
+ localePickerAttr === null
193
+ ? undefined
194
+ : localePickerAttr !== 'false' && localePickerAttr !== 'off';
195
+
196
+ // Prefer the explicit schema property (schema mode) over a fetched one.
197
+ const schema = this.#schema ?? this.#apiSchema;
198
+
199
+ if (this.#apiError && !schema) {
200
+ this.#root.render(
201
+ createElement(
202
+ 'div',
203
+ { className: 'shift-survey-error', role: 'alert' },
204
+ this.#apiError.message,
205
+ ),
206
+ );
207
+ return;
208
+ }
209
+ if (!schema) {
210
+ this.#root.render(
211
+ createElement('div', { className: 'shift-survey-loading' }, 'Loading…'),
212
+ );
213
+ return;
214
+ }
215
+
216
+ // Wire onSubmit. Schema mode uses the property the host set; API mode hits
217
+ // the public submit endpoint via SurveyClient.
218
+ const onSubmit = this.#schema
219
+ ? (this.#onSubmit ??
220
+ ((submission: SurveySubmission) => {
221
+ this.#dispatch('survey:completed', { ...submission });
222
+ }))
223
+ : async (submission: SurveySubmission) => {
224
+ if (!apiBase || !instanceId)
225
+ throw new Error('shift-survey: API mode requires both instance-id and api-base attributes.');
226
+ const client = new SurveyClient({ baseUrl: apiBase });
227
+ await client.submitResponse(instanceId, submission);
228
+ };
229
+
230
+ const activeScreenId = this.activeScreenId;
231
+
232
+ this.#root.render(
233
+ createElement(SurveyRenderer, {
234
+ schema,
235
+ onSubmit,
236
+ ...(locale ? { locale } : {}),
237
+ ...(showLocalePicker === undefined ? {} : { showLocalePicker }),
238
+ // Mirror the respondent's pick back onto the element so a host can read it
239
+ // (and so the attribute stays an accurate description of what is showing).
240
+ onLocaleChange: (next: string) => {
241
+ if (this.getAttribute('locale') !== next) this.setAttribute('locale', next);
242
+ this.#dispatch('survey:locale-changed', { locale: next });
243
+ },
244
+ ...(activeScreenId
245
+ ? { activeScreenId, activeScreenJumpToken: this.#activeScreenJumpToken }
246
+ : {}),
247
+ // Let the element be the resume key in API mode so two surveys on the
248
+ // same host page don't clobber each other.
249
+ ...(instanceId ? { resumeKey: instanceId } : {}),
250
+ ...(agentMode ? { submissionMeta: { mode: 'agent' } } : {}),
251
+ // CustomEvents are the web-component's channel; postMessage stays opt-in
252
+ // via iframe auto-detect on the enclosing page (unchanged).
253
+ onScreenChange: (screenId: string | null) =>
254
+ this.#dispatch('survey:screen-changed', { screenId }),
255
+ onCompleted: (screenId: string | null) =>
256
+ this.#dispatch('survey:completed', { screenId }),
257
+ }),
258
+ );
259
+
260
+ if (!this.#loadedDispatched) {
261
+ this.#loadedDispatched = true;
262
+ this.#dispatch('survey:loaded', {});
263
+ }
264
+ }
265
+
266
+ #loadedDispatched = false;
267
+
268
+ #dispatch(type: string, detail: Record<string, unknown>): void {
269
+ this.dispatchEvent(
270
+ new CustomEvent(type, { detail, bubbles: true, composed: true }),
271
+ );
272
+ }
273
+ }
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ import './library-fonts.js'; // first: see the file
2
+ import { ShiftSurveyElement } from './ShiftSurveyElement.js';
3
+
4
+ export { ShiftSurveyElement };
5
+
6
+ /** Register the `<shift-survey>` custom element. Idempotent — calling twice on
7
+ * the same page is a no-op. Consumers who want a different tag name can import
8
+ * `ShiftSurveyElement` directly and call `customElements.define(...)` with it. */
9
+ export function registerShiftSurvey(tagName = 'shift-survey'): void {
10
+ if (typeof window === 'undefined' || typeof customElements === 'undefined') return;
11
+ if (customElements.get(tagName)) return;
12
+ customElements.define(tagName, ShiftSurveyElement);
13
+ }
14
+
15
+ // Auto-register on import so plain `<script type="module" src="...">` embeds
16
+ // Just Work™. Callers who want to rename the tag should import the class and
17
+ // register themselves BEFORE loading this module, or set the tag name before
18
+ // import. This default covers the 90% case.
19
+ registerShiftSurvey();
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The booking question's calendar comes from adp-web-components, whose global script adds a
3
+ * Google Fonts stylesheet to the page when it first loads. The survey sets its own font on the
4
+ * calendar, so it opts out. This must be the entry's first import: imports run in order, before
5
+ * any module body, and the library reads the flag as it loads.
6
+ */
7
+ if (typeof window !== 'undefined') (window as { adpWebComponentsFonts?: boolean }).adpWebComponentsFonts ??= false;
@@ -0,0 +1,6 @@
1
+ /** Vite's `?inline` query returns CSS as a string at build time. Declare the
2
+ * module shape so TS resolves the import. */
3
+ declare module '*.css?inline' {
4
+ const content: string;
5
+ export default content;
6
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.