@edvizion/astro-components 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.
package/README.md ADDED
@@ -0,0 +1,224 @@
1
+ # @edvizion/astro-components
2
+
3
+ Shared Astro components for Edvizion apps.
4
+
5
+ - [`<SchoolSearch>`](#schoolsearch) — school picker backed by the Edvizion School Directory
6
+ - [`<EdvizionTheme>`](#theming) — shared design tokens for every component
7
+
8
+ ## SchoolSearch
9
+
10
+ ```astro
11
+ ---
12
+ import { SchoolSearch } from '@edvizion/astro-components';
13
+ ---
14
+ <form>
15
+ <SchoolSearch
16
+ name="schoolId"
17
+ required
18
+ defaultState="AZ"
19
+ stateLabel="School of Attendance State"
20
+ schoolLabel="School of Attendance Name"
21
+ />
22
+ <button>Save</button>
23
+ </form>
24
+ ```
25
+
26
+ The user picks a US state (50 + DC), types a school name or city, and presses
27
+ **Enter** or **Search**. Nothing is fetched while typing. Picking a result sets
28
+ the value to that school's `edvizionId`.
29
+
30
+ Requests go to `https://api.edvizion.com/schools/*` with no `Authorization`
31
+ header — Edvizion Auth injects the token.
32
+
33
+ ### Recommending a missing school
34
+
35
+ After any search, the user sees **"Can't find your school? Recommend it"**. That
36
+ opens an inline panel (school name, prefilled from the search, plus optional
37
+ notes) which POSTs `{ name, state, notes }` to `/schools/recommendations`.
38
+
39
+ On a **202** the component's value becomes **`sch_00000000-0000-0000-0000-000000000000`** and the card
40
+ reads "Recommended — pending review". Recommendations are reviewed by a person
41
+ in a Google Space and never enter the directory, so there is no real
42
+ `edvizionId` yet. The submitted name and state are on
43
+ `picker.selectedSchool.recommendation`:
44
+
45
+ ```js
46
+ picker.value; // "sch_00000000-0000-0000-0000-000000000000"
47
+ picker.selectedSchool.recommendation; // { name, state, notes, requestId, submittedAt }
48
+ ```
49
+
50
+ A native form post only carries `sch_00000000-0000-0000-0000-000000000000`, so if your backend needs the
51
+ recommended name, read it from `selectedSchool` (or the `edv-recommend` event)
52
+ and send it along.
53
+
54
+ Any failure keeps the panel open with the user's text so they can retry. A 502
55
+ means the recommendation reached nobody.
56
+
57
+ Hide the feature with `recommend={false}`. `RECOMMENDED_SCHOOL_ID` is exported
58
+ for comparisons.
59
+
60
+ ### Props
61
+
62
+ | prop | default | |
63
+ | --- | --- | --- |
64
+ | `name` | — | Form field name the `edvizionId` is submitted under |
65
+ | `required` | `false` | Requires both a state and a school |
66
+ | `disabled` | `false` | Also disabled by a disabled `<fieldset>` |
67
+ | `defaultState` | none | USPS code, case-insensitive. Unsupported codes are ignored with a warning |
68
+ | `stateLabel` | `"State"` | Label for the state select |
69
+ | `schoolLabel` | `"School"` | Label for the search box |
70
+ | `placeholder` | `"School name or city"` | |
71
+ | `hint` | `"Search by school name or city, then press Enter."` | |
72
+ | `limit` | `20` | Results per page (max 100). "Show more results" pages beyond this |
73
+ | `value` / `valueName` | — | Pre-selected school for edit forms (`valueName` is the display name). `sch_00000000-0000-0000-0000-000000000000` shows as pending review |
74
+ | `endpoint` | production API | Override the search endpoint (e.g. staging) |
75
+ | `recommend` | `true` | Offer "Recommend it" when a school can't be found |
76
+ | `recommendEndpoint` | production API | Override the recommendation endpoint |
77
+ | `form` | — | Associate with a form by id when not nested inside it |
78
+
79
+ Any other attribute (`id`, `class`, `data-*`, …) is passed to the element.
80
+
81
+ ### Reading value and validity
82
+
83
+ `<SchoolSearch>` renders an `<edv-school-search>` element that is a real
84
+ form-associated control, so you read it exactly like an `<input>`.
85
+
86
+ **Through the form** — nothing special:
87
+
88
+ ```js
89
+ const form = document.querySelector('form');
90
+ new FormData(form).get('schoolId'); // "sch_0a19…" or null
91
+ form.checkValidity(); // false until a state and school are chosen
92
+ form.elements.schoolId; // the <edv-school-search> element
93
+ ```
94
+
95
+ The internal state select and search box are never submitted; only the
96
+ `edvizionId` is. Native form submission is blocked while it is invalid, and the
97
+ component shows inline errors.
98
+
99
+ **From the element directly:**
100
+
101
+ ```js
102
+ const picker = document.querySelector('edv-school-search');
103
+
104
+ picker.value; // "sch_…" or ""
105
+ picker.selectedSchool; // { edvizionId, name, city, state } | null
106
+ picker.state; // "AZ" or ""
107
+ picker.validity; // ValidityState — .valid, .valueMissing, .customError
108
+ picker.validationMessage; // "Select a state." | "Search for and select a school." | custom
109
+ picker.checkValidity();
110
+ picker.reportValidity();
111
+ picker.setCustomValidity('That school is not eligible.'); // '' to clear
112
+
113
+ picker.state = 'NV'; // programmatic; clears the selection
114
+ picker.selectSchool({ edvizionId, name, city, state });
115
+ ```
116
+
117
+ **Events** (all bubble from `<edv-school-search>`):
118
+
119
+ | event | when |
120
+ | --- | --- |
121
+ | `input`, `change` | the selected school changes (chosen, cleared, or cleared by a state change). Read `event.target.value` |
122
+ | `edv-search` | results loaded. `detail: { query, state, offset, results }` |
123
+ | `edv-search-error` | a search failed. `detail: { status, code, requestId, message }` |
124
+ | `edv-recommend` | a recommendation was accepted (202). `detail: { name, state, notes, requestId, submittedAt }` |
125
+ | `edv-recommend-error` | a recommendation failed. Same `detail` shape as `edv-search-error` |
126
+
127
+ Typing in the search box does **not** fire `input` on the component; its value
128
+ only changes when a school is selected.
129
+
130
+ In TypeScript, `document.querySelector('edv-school-search')` is typed as
131
+ `EdvSchoolSearchElement`.
132
+
133
+ ### Accessibility
134
+
135
+ Labelled controls, a polite live region for search status, results as an ARIA
136
+ listbox (↓ from the search box, ↑/↓/Home/End, Enter or Space to choose, Esc to
137
+ return), focus moved to the "Change school" button after selecting, and inline
138
+ errors tied to the controls with `aria-invalid` / `aria-describedby`. Axe runs
139
+ in the e2e suite.
140
+
141
+ ## Theming
142
+
143
+ Every component reads `--edv-*` CSS custom properties, with built-in defaults,
144
+ so they look right with no setup. Custom properties inherit through shadow DOM,
145
+ so one theme restyles every component.
146
+
147
+ ```astro
148
+ ---
149
+ import { EdvizionTheme } from '@edvizion/astro-components';
150
+ ---
151
+ <!-- Whole page -->
152
+ <EdvizionTheme global theme={{ colorPrimary: '#0f766e', radiusSmall: '0' }} />
153
+
154
+ <!-- Or just a subtree -->
155
+ <EdvizionTheme theme={{ colorPrimary: '#7c3aed' }}>
156
+ <SchoolSearch name="school" />
157
+ </EdvizionTheme>
158
+ ```
159
+
160
+ Or set the properties in any stylesheet:
161
+
162
+ ```css
163
+ :root { --edv-color-primary: #0f766e; --edv-font-family: Inter, sans-serif; }
164
+ ```
165
+
166
+ The token list is `TOKEN_PROPERTIES` in [src/theme/tokens.ts](src/theme/tokens.ts).
167
+ For one-off overrides beyond tokens, components expose `::part()`:
168
+
169
+ ```css
170
+ edv-school-search::part(search-button) { text-transform: uppercase; }
171
+ ```
172
+
173
+ SchoolSearch parts: `root`, `state-field`, `school-field`, `label`, `select`,
174
+ `input`, `button`, `search-button`, `change-button`, `more-button`, `hint`,
175
+ `error`, `status`, `results`, `option`, `option-name`, `option-meta`,
176
+ `selection`, `selection-name`, `selection-meta`, `recommend-prompt`,
177
+ `recommend-button`, `recommend`, `recommend-input`, `textarea`,
178
+ `recommend-notes`, `recommend-submit`, `recommend-cancel`.
179
+
180
+ New components should build their shadow styles on `baseComponentStyles` and
181
+ `cssVar()` from `src/theme/` so they pick up the same tokens.
182
+
183
+ ## Development
184
+
185
+ ```bash
186
+ npm run dev # playground at http://localhost:4391
187
+ npm run test:e2e # Playwright: Chromium, Firefox, WebKit
188
+ npm run check # astro check (TypeScript)
189
+ ```
190
+
191
+ The e2e suite mocks both endpoints with `page.route`, so it needs no token and
192
+ never hits production. Fixture pages live in `playground/pages/fixtures/`. It
193
+ runs its own server on port 4399, so it can run alongside `npm run dev`.
194
+
195
+ ### Trying it against the real API
196
+
197
+ `localhost` is on the API's CORS allow-list. Start `npm run dev`, open
198
+ http://localhost:4391, and paste this into the DevTools console. It stands in
199
+ for Edvizion Auth by adding your token to requests to `api.edvizion.com`, and it
200
+ prompts for the token so the token never lands in your console history:
201
+
202
+ ```js
203
+ (() => {
204
+ const token = prompt('Edvizion Cognito JWT (ID or access token)')?.trim().replace(/^Bearer\s+/i, '');
205
+ if (!token) return console.warn('No token entered; fetch left unchanged.');
206
+ const realFetch = (window.__edvRealFetch ??= window.fetch.bind(window));
207
+ window.fetch = (input, init = {}) => {
208
+ const url = new URL(input instanceof Request ? input.url : String(input), location.href);
209
+ if (url.origin !== 'https://api.edvizion.com') return realFetch(input, init);
210
+ const headers = new Headers(init.headers ?? (input instanceof Request ? input.headers : undefined));
211
+ headers.set('authorization', `Bearer ${token}`);
212
+ return realFetch(input, { ...init, headers });
213
+ };
214
+ try {
215
+ const { exp } = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
216
+ console.info(`Edvizion token injected. Expires ${new Date(exp * 1000).toLocaleString()}. Reload the page to remove it.`);
217
+ } catch {
218
+ console.info('Edvizion token injected. Reload the page to remove it.');
219
+ }
220
+ })();
221
+ ```
222
+
223
+ Recommendations sent this way are **real**: they post to the review Google
224
+ Space.
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@edvizion/astro-components",
3
+ "version": "0.1.0",
4
+ "description": "Shared Astro components for Edvizion apps.",
5
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./SchoolSearch.astro": "./src/components/SchoolSearch/SchoolSearch.astro",
10
+ "./EdvizionTheme.astro": "./src/theme/EdvizionTheme.astro",
11
+ "./school-search-element": "./src/components/SchoolSearch/school-search-element.ts"
12
+ },
13
+ "files": [
14
+ "src"
15
+ ],
16
+ "keywords": [
17
+ "astro-component",
18
+ "withastro"
19
+ ],
20
+ "scripts": {
21
+ "dev": "astro dev",
22
+ "build": "astro build",
23
+ "check": "astro check",
24
+ "test:e2e": "playwright test",
25
+ "test:e2e:ui": "playwright test --ui"
26
+ },
27
+ "peerDependencies": {
28
+ "astro": ">=5"
29
+ },
30
+ "devDependencies": {
31
+ "@astrojs/check": "^0.9.10",
32
+ "@axe-core/playwright": "^4.13.0",
33
+ "@playwright/test": "^1.63.0",
34
+ "@types/node": "^26.6.3",
35
+ "astro": "^7.3.5",
36
+ "typescript": "^6.0.3"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ }
41
+ }
@@ -0,0 +1,98 @@
1
+ ---
2
+ /**
3
+ * School picker backed by the Edvizion School Directory. Behaves like a
4
+ * native form control: submits the chosen school's `edvizionId` under `name`,
5
+ * participates in form validation, reset, and disabled fieldsets.
6
+ */
7
+ import type { HTMLAttributes } from 'astro/types';
8
+ import { normalizeStateCode } from './us-states';
9
+
10
+ interface Props extends Omit<HTMLAttributes<'div'>, 'slot'> {
11
+ /** Form field name the `edvizionId` is submitted under. */
12
+ name?: string;
13
+ required?: boolean;
14
+ disabled?: boolean;
15
+ /** Pre-selected USPS state code, e.g. "AZ". Case-insensitive. */
16
+ defaultState?: string;
17
+ /** Label for the state select. Default: "State". e.g. "School of Attendance State". */
18
+ stateLabel?: string;
19
+ /** Label for the school search field. Default: "School". e.g. "School of Attendance Name". */
20
+ schoolLabel?: string;
21
+ placeholder?: string;
22
+ /** Helper text under the search field. */
23
+ hint?: string;
24
+ /** Results per page (max 100). Default: 20. */
25
+ limit?: number;
26
+ /** Override the search endpoint (e.g. a staging API). */
27
+ endpoint?: string;
28
+ /**
29
+ * Let users recommend a school that search can't find. On success the value
30
+ * becomes `sch_00000000-0000-0000-0000-000000000000`. Default: true.
31
+ */
32
+ recommend?: boolean;
33
+ /** Override the recommendation endpoint. */
34
+ recommendEndpoint?: string;
35
+ /** Pre-selected school `edvizionId` (for edit forms; may be `sch_00000000-0000-0000-0000-000000000000`). */
36
+ value?: string;
37
+ /** Display name for a pre-selected `value`. */
38
+ valueName?: string;
39
+ /** Associate with a form by id when not nested inside it. */
40
+ form?: string;
41
+ }
42
+
43
+ const {
44
+ name,
45
+ required,
46
+ disabled,
47
+ defaultState,
48
+ stateLabel,
49
+ schoolLabel,
50
+ placeholder,
51
+ hint,
52
+ limit,
53
+ endpoint,
54
+ recommend = true,
55
+ recommendEndpoint,
56
+ value,
57
+ valueName,
58
+ ...rest
59
+ } = Astro.props;
60
+
61
+ // Astro stringifies booleans on custom elements (`required="false"`), and any
62
+ // present attribute counts as on — so omit false flags entirely.
63
+ const flag = (on: boolean | undefined) => (on ? '' : undefined);
64
+
65
+ const state = normalizeStateCode(defaultState);
66
+ if (defaultState && !state) {
67
+ console.warn(`<SchoolSearch>: ignoring unsupported defaultState "${defaultState}".`);
68
+ }
69
+ ---
70
+
71
+ <edv-school-search
72
+ {...rest}
73
+ name={name}
74
+ required={flag(required)}
75
+ disabled={flag(disabled)}
76
+ default-state={state ?? undefined}
77
+ state-label={stateLabel}
78
+ school-label={schoolLabel}
79
+ placeholder={placeholder}
80
+ hint={hint}
81
+ limit={limit}
82
+ endpoint={endpoint}
83
+ no-recommend={flag(!recommend)}
84
+ recommend-endpoint={recommendEndpoint}
85
+ value={value}
86
+ value-name={valueName}
87
+ ></edv-school-search>
88
+
89
+ <script>
90
+ import './school-search-element';
91
+ </script>
92
+
93
+ <style is:global>
94
+ edv-school-search:not(:defined) {
95
+ display: block;
96
+ min-height: 9.5rem;
97
+ }
98
+ </style>
@@ -0,0 +1,156 @@
1
+ import type { SchoolRecommendationResponse, SchoolSearchResponse } from './types';
2
+
3
+ export const DEFAULT_SEARCH_ENDPOINT = 'https://api.edvizion.com/schools/search';
4
+ export const DEFAULT_RECOMMEND_ENDPOINT = 'https://api.edvizion.com/schools/recommendations';
5
+ /** The value a form receives when the user recommended a missing school. */
6
+ export const RECOMMENDED_SCHOOL_ID = 'sch_00000000-0000-0000-0000-000000000000';
7
+ export const MAX_QUERY_LENGTH = 200;
8
+ export const DEFAULT_LIMIT = 20;
9
+ export const MAX_LIMIT = 100;
10
+ export const MAX_OFFSET = 1000;
11
+
12
+ /**
13
+ * Mirrors the API's own tokenizer (anything that is not a letter, digit,
14
+ * apostrophe or ampersand splits terms) so obviously-invalid queries are
15
+ * caught before a round trip. The API remains the authority.
16
+ */
17
+ export function validateQuery(raw: string): string | null {
18
+ const query = raw.trim();
19
+ if (!query) return 'Enter a school name or city to search.';
20
+ if (query.length > MAX_QUERY_LENGTH) return `Search must be ${MAX_QUERY_LENGTH} characters or fewer.`;
21
+ const terms = query.split(/[^\p{L}\p{N}'&]+/u).filter(Boolean);
22
+ if (terms.length === 0) return 'Enter letters or numbers to search.';
23
+ if (!terms.some((term) => term.length >= 2)) return 'Enter at least 2 characters to search.';
24
+ return null;
25
+ }
26
+
27
+ export class SchoolSearchError extends Error {
28
+ constructor(
29
+ message: string,
30
+ readonly status: number | null,
31
+ readonly code: string | null,
32
+ readonly requestId: string | null,
33
+ ) {
34
+ super(message);
35
+ this.name = 'SchoolSearchError';
36
+ }
37
+ }
38
+
39
+ export interface SearchSchoolsOptions {
40
+ query: string;
41
+ state: string;
42
+ limit?: number;
43
+ offset?: number;
44
+ endpoint?: string;
45
+ signal?: AbortSignal;
46
+ }
47
+
48
+ /** Calls the school search API. */
49
+ export async function searchSchools({
50
+ query,
51
+ state,
52
+ limit = DEFAULT_LIMIT,
53
+ offset = 0,
54
+ endpoint = DEFAULT_SEARCH_ENDPOINT,
55
+ signal,
56
+ }: SearchSchoolsOptions): Promise<SchoolSearchResponse> {
57
+ const url = new URL(endpoint, globalThis.location?.href);
58
+ url.searchParams.set('q', query.trim());
59
+ url.searchParams.set('state', state);
60
+ url.searchParams.set('limit', String(limit));
61
+ if (offset > 0) url.searchParams.set('offset', String(offset));
62
+
63
+ const { response, body } = await send(url, { method: 'GET', signal }, 'search');
64
+ if (!body || !Array.isArray((body as SchoolSearchResponse).results)) {
65
+ throw new SchoolSearchError(unavailable.search, response.status, null, response.headers.get('x-request-id'));
66
+ }
67
+ return body as SchoolSearchResponse;
68
+ }
69
+
70
+ export interface RecommendSchoolOptions {
71
+ name: string;
72
+ state: string;
73
+ notes?: string;
74
+ endpoint?: string;
75
+ signal?: AbortSignal;
76
+ }
77
+
78
+ /**
79
+ * Reports a school missing from the directory. It is posted to a Google Space
80
+ * for a person to review — it never enters the directory or search results.
81
+ * Success is 202. A 502 means the recommendation reached nobody and must be
82
+ * retried. The recommender is taken from the injected token, not the body.
83
+ */
84
+ export async function recommendSchool({
85
+ name,
86
+ state,
87
+ notes,
88
+ endpoint = DEFAULT_RECOMMEND_ENDPOINT,
89
+ signal,
90
+ }: RecommendSchoolOptions): Promise<SchoolRecommendationResponse> {
91
+ const payload: Record<string, string> = { name: name.trim(), state };
92
+ if (notes?.trim()) payload.notes = notes.trim();
93
+
94
+ const { body } = await send(
95
+ new URL(endpoint, globalThis.location?.href),
96
+ { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload), signal },
97
+ 'recommend',
98
+ );
99
+ return body as SchoolRecommendationResponse;
100
+ }
101
+
102
+ type Operation = 'search' | 'recommend';
103
+
104
+ const unavailable: Record<Operation, string> = {
105
+ search: 'School search is unavailable right now. Please try again in a moment.',
106
+ recommend: 'Your recommendation could not be sent, so it was not received. Please try again.',
107
+ };
108
+
109
+ /**
110
+ * Sends a request. No Authorization header is set here: Edvizion Auth
111
+ * injects the bearer token into outgoing requests.
112
+ */
113
+ async function send(url: URL, init: RequestInit, operation: Operation) {
114
+ let response: Response;
115
+ try {
116
+ response = await fetch(url, { ...init, headers: { accept: 'application/json', ...init.headers } });
117
+ } catch (error) {
118
+ if ((error as Error)?.name === 'AbortError') throw error;
119
+ throw new SchoolSearchError(unavailable[operation], null, null, null);
120
+ }
121
+
122
+ const requestId = response.headers.get('x-request-id');
123
+ const body: unknown = await response.json().catch(() => null);
124
+
125
+ if (!response.ok) {
126
+ const { code, message } = readError(body);
127
+ throw new SchoolSearchError(messageFor(operation, response.status, message), response.status, code, requestId);
128
+ }
129
+ return { response, body };
130
+ }
131
+
132
+ function messageFor(operation: Operation, status: number, serverMessage: string | null): string {
133
+ if (status === 400) {
134
+ return serverMessage ?? (operation === 'search' ? 'That search could not be run. Try different words.' : 'Check the school name and try again.');
135
+ }
136
+ if (status === 401 || status === 403) {
137
+ return operation === 'search'
138
+ ? 'You need to be signed in to search for schools. Sign in and try again.'
139
+ : 'You need to be signed in to recommend a school. Sign in and try again.';
140
+ }
141
+ if (operation === 'recommend' && status === 500) {
142
+ return 'Recommending schools is unavailable right now. Please try again later.';
143
+ }
144
+ return unavailable[operation];
145
+ }
146
+
147
+ // The API's error envelope is `{ error: { code, message } }`; tolerate a flat one too.
148
+ function readError(body: unknown): { code: string | null; message: string | null } {
149
+ const source = (body as { error?: unknown })?.error ?? body;
150
+ if (!source || typeof source !== 'object') return { code: null, message: null };
151
+ const { code, message } = source as { code?: unknown; message?: unknown };
152
+ return {
153
+ code: typeof code === 'string' ? code : null,
154
+ message: typeof message === 'string' ? message : null,
155
+ };
156
+ }