@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 +224 -0
- package/package.json +41 -0
- package/src/components/SchoolSearch/SchoolSearch.astro +98 -0
- package/src/components/SchoolSearch/api.ts +156 -0
- package/src/components/SchoolSearch/school-search-element.ts +993 -0
- package/src/components/SchoolSearch/types.ts +64 -0
- package/src/components/SchoolSearch/us-states.ts +63 -0
- package/src/index.ts +35 -0
- package/src/theme/EdvizionTheme.astro +32 -0
- package/src/theme/component-styles.ts +70 -0
- package/src/theme/tokens.ts +86 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** One hit from `GET /schools/search`. Address schools by `edvizionId`. */
|
|
2
|
+
export interface SchoolSearchResult {
|
|
3
|
+
edvizionId: string;
|
|
4
|
+
slug: string;
|
|
5
|
+
name: string;
|
|
6
|
+
/** Always null today — no districts have been imported yet. */
|
|
7
|
+
districtEdvizionId: string | null;
|
|
8
|
+
city: string | null;
|
|
9
|
+
state: string;
|
|
10
|
+
type: string | null;
|
|
11
|
+
gradeSpan: { low: string | null; high: string | null } | null;
|
|
12
|
+
website: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SchoolSearchResponse {
|
|
16
|
+
requestId: string;
|
|
17
|
+
query: string;
|
|
18
|
+
state: string | null;
|
|
19
|
+
count: number;
|
|
20
|
+
/** Ordered best match first. */
|
|
21
|
+
results: SchoolSearchResult[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The minimum a selection needs; a prefilled value may only have these. */
|
|
25
|
+
export interface SelectedSchool {
|
|
26
|
+
/** `"sch_00000000-0000-0000-0000-000000000000"` when the user recommended a missing school. */
|
|
27
|
+
edvizionId: string;
|
|
28
|
+
name: string;
|
|
29
|
+
city?: string | null;
|
|
30
|
+
state?: string | null;
|
|
31
|
+
/** Present when the user recommended this school rather than choosing one. */
|
|
32
|
+
recommendation?: SchoolRecommendation;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `POST /schools/recommendations` — 202 Accepted. */
|
|
36
|
+
export interface SchoolRecommendationResponse {
|
|
37
|
+
requestId: string;
|
|
38
|
+
status: 'received_for_review';
|
|
39
|
+
name: string;
|
|
40
|
+
state: string;
|
|
41
|
+
submittedAt: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SchoolRecommendation {
|
|
45
|
+
name: string;
|
|
46
|
+
state: string;
|
|
47
|
+
notes: string | null;
|
|
48
|
+
requestId: string | null;
|
|
49
|
+
submittedAt: string | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SchoolSearchEventDetail {
|
|
53
|
+
query: string;
|
|
54
|
+
state: string;
|
|
55
|
+
offset: number;
|
|
56
|
+
results: SchoolSearchResult[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface SchoolSearchErrorDetail {
|
|
60
|
+
status: number | null;
|
|
61
|
+
code: string | null;
|
|
62
|
+
requestId: string | null;
|
|
63
|
+
message: string;
|
|
64
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** The 50 states plus DC, as USPS codes. The search API is US-only for now. */
|
|
2
|
+
export const US_STATES = [
|
|
3
|
+
{ code: 'AL', name: 'Alabama' },
|
|
4
|
+
{ code: 'AK', name: 'Alaska' },
|
|
5
|
+
{ code: 'AZ', name: 'Arizona' },
|
|
6
|
+
{ code: 'AR', name: 'Arkansas' },
|
|
7
|
+
{ code: 'CA', name: 'California' },
|
|
8
|
+
{ code: 'CO', name: 'Colorado' },
|
|
9
|
+
{ code: 'CT', name: 'Connecticut' },
|
|
10
|
+
{ code: 'DE', name: 'Delaware' },
|
|
11
|
+
{ code: 'DC', name: 'District of Columbia' },
|
|
12
|
+
{ code: 'FL', name: 'Florida' },
|
|
13
|
+
{ code: 'GA', name: 'Georgia' },
|
|
14
|
+
{ code: 'HI', name: 'Hawaii' },
|
|
15
|
+
{ code: 'ID', name: 'Idaho' },
|
|
16
|
+
{ code: 'IL', name: 'Illinois' },
|
|
17
|
+
{ code: 'IN', name: 'Indiana' },
|
|
18
|
+
{ code: 'IA', name: 'Iowa' },
|
|
19
|
+
{ code: 'KS', name: 'Kansas' },
|
|
20
|
+
{ code: 'KY', name: 'Kentucky' },
|
|
21
|
+
{ code: 'LA', name: 'Louisiana' },
|
|
22
|
+
{ code: 'ME', name: 'Maine' },
|
|
23
|
+
{ code: 'MD', name: 'Maryland' },
|
|
24
|
+
{ code: 'MA', name: 'Massachusetts' },
|
|
25
|
+
{ code: 'MI', name: 'Michigan' },
|
|
26
|
+
{ code: 'MN', name: 'Minnesota' },
|
|
27
|
+
{ code: 'MS', name: 'Mississippi' },
|
|
28
|
+
{ code: 'MO', name: 'Missouri' },
|
|
29
|
+
{ code: 'MT', name: 'Montana' },
|
|
30
|
+
{ code: 'NE', name: 'Nebraska' },
|
|
31
|
+
{ code: 'NV', name: 'Nevada' },
|
|
32
|
+
{ code: 'NH', name: 'New Hampshire' },
|
|
33
|
+
{ code: 'NJ', name: 'New Jersey' },
|
|
34
|
+
{ code: 'NM', name: 'New Mexico' },
|
|
35
|
+
{ code: 'NY', name: 'New York' },
|
|
36
|
+
{ code: 'NC', name: 'North Carolina' },
|
|
37
|
+
{ code: 'ND', name: 'North Dakota' },
|
|
38
|
+
{ code: 'OH', name: 'Ohio' },
|
|
39
|
+
{ code: 'OK', name: 'Oklahoma' },
|
|
40
|
+
{ code: 'OR', name: 'Oregon' },
|
|
41
|
+
{ code: 'PA', name: 'Pennsylvania' },
|
|
42
|
+
{ code: 'RI', name: 'Rhode Island' },
|
|
43
|
+
{ code: 'SC', name: 'South Carolina' },
|
|
44
|
+
{ code: 'SD', name: 'South Dakota' },
|
|
45
|
+
{ code: 'TN', name: 'Tennessee' },
|
|
46
|
+
{ code: 'TX', name: 'Texas' },
|
|
47
|
+
{ code: 'UT', name: 'Utah' },
|
|
48
|
+
{ code: 'VT', name: 'Vermont' },
|
|
49
|
+
{ code: 'VA', name: 'Virginia' },
|
|
50
|
+
{ code: 'WA', name: 'Washington' },
|
|
51
|
+
{ code: 'WV', name: 'West Virginia' },
|
|
52
|
+
{ code: 'WI', name: 'Wisconsin' },
|
|
53
|
+
{ code: 'WY', name: 'Wyoming' },
|
|
54
|
+
] as const;
|
|
55
|
+
|
|
56
|
+
export type UsStateCode = (typeof US_STATES)[number]['code'];
|
|
57
|
+
|
|
58
|
+
/** Returns the upper-cased code if it is a supported state, otherwise null. */
|
|
59
|
+
export function normalizeStateCode(value: string | null | undefined): UsStateCode | null {
|
|
60
|
+
if (!value) return null;
|
|
61
|
+
const code = value.trim().toUpperCase();
|
|
62
|
+
return US_STATES.some((s) => s.code === code) ? (code as UsStateCode) : null;
|
|
63
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export { default as SchoolSearch } from './components/SchoolSearch/SchoolSearch.astro';
|
|
2
|
+
export { default as EdvizionTheme } from './theme/EdvizionTheme.astro';
|
|
3
|
+
|
|
4
|
+
export {
|
|
5
|
+
DEFAULT_RECOMMEND_ENDPOINT,
|
|
6
|
+
DEFAULT_SEARCH_ENDPOINT,
|
|
7
|
+
RECOMMENDED_SCHOOL_ID,
|
|
8
|
+
recommendSchool,
|
|
9
|
+
SchoolSearchError,
|
|
10
|
+
searchSchools,
|
|
11
|
+
validateQuery,
|
|
12
|
+
type RecommendSchoolOptions,
|
|
13
|
+
type SearchSchoolsOptions,
|
|
14
|
+
} from './components/SchoolSearch/api';
|
|
15
|
+
export type { EdvSchoolSearchElement } from './components/SchoolSearch/school-search-element';
|
|
16
|
+
export type {
|
|
17
|
+
SchoolRecommendation,
|
|
18
|
+
SchoolRecommendationResponse,
|
|
19
|
+
SchoolSearchErrorDetail,
|
|
20
|
+
SchoolSearchEventDetail,
|
|
21
|
+
SchoolSearchResponse,
|
|
22
|
+
SchoolSearchResult,
|
|
23
|
+
SelectedSchool,
|
|
24
|
+
} from './components/SchoolSearch/types';
|
|
25
|
+
export { US_STATES, normalizeStateCode, type UsStateCode } from './components/SchoolSearch/us-states';
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
DEFAULT_TOKENS,
|
|
29
|
+
TOKEN_PROPERTIES,
|
|
30
|
+
cssVar,
|
|
31
|
+
tokenDeclarations,
|
|
32
|
+
type ThemeToken,
|
|
33
|
+
type ThemeTokens,
|
|
34
|
+
} from './theme/tokens';
|
|
35
|
+
export { baseComponentStyles } from './theme/component-styles';
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Applies Edvizion design tokens to everything it wraps (or to the whole page
|
|
4
|
+
* with `global`). Every Edvizion component reads these tokens, including
|
|
5
|
+
* inside shadow DOM, so one theme restyles all of them.
|
|
6
|
+
*/
|
|
7
|
+
import { DEFAULT_TOKENS, tokenDeclarations, type ThemeTokens } from './tokens';
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
/** Token overrides, merged over the Edvizion defaults. */
|
|
11
|
+
theme?: Partial<ThemeTokens>;
|
|
12
|
+
/** Apply to `:root` instead of wrapping the slot. */
|
|
13
|
+
global?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const { theme = {}, global = false } = Astro.props;
|
|
17
|
+
|
|
18
|
+
const scopeId = `edv-${Math.random().toString(36).slice(2, 10)}`;
|
|
19
|
+
const selector = global ? ':root' : `[data-edv-theme="${scopeId}"]`;
|
|
20
|
+
const css = `${selector} { ${tokenDeclarations({ ...DEFAULT_TOKENS, ...theme })} }`;
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
<style set:html={css}></style>
|
|
24
|
+
{
|
|
25
|
+
global ? (
|
|
26
|
+
<slot />
|
|
27
|
+
) : (
|
|
28
|
+
<div data-edv-theme={scopeId} style="display: contents">
|
|
29
|
+
<slot />
|
|
30
|
+
</div>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { cssVar } from './tokens';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Base styles every Edvizion component's shadow root starts from, so
|
|
5
|
+
* typography, controls and focus treatment look the same across components.
|
|
6
|
+
*/
|
|
7
|
+
export const baseComponentStyles = /* css */ `
|
|
8
|
+
:host {
|
|
9
|
+
display: block;
|
|
10
|
+
font-family: ${cssVar('fontFamily')};
|
|
11
|
+
font-size: ${cssVar('fontSize')};
|
|
12
|
+
line-height: ${cssVar('lineHeight')};
|
|
13
|
+
color: ${cssVar('colorText')};
|
|
14
|
+
}
|
|
15
|
+
:host([hidden]) { display: none; }
|
|
16
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
17
|
+
|
|
18
|
+
.edv-label {
|
|
19
|
+
display: block;
|
|
20
|
+
font-weight: ${cssVar('fontWeightStrong')};
|
|
21
|
+
margin-block-end: calc(${cssVar('space')} * 0.5);
|
|
22
|
+
}
|
|
23
|
+
.edv-required { color: ${cssVar('colorDanger')}; margin-inline-start: 0.125em; }
|
|
24
|
+
.edv-hint { margin: calc(${cssVar('space')} * 0.5) 0 0; font-size: ${cssVar('fontSizeSmall')}; color: ${cssVar('colorTextMuted')}; }
|
|
25
|
+
.edv-error { margin: calc(${cssVar('space')} * 0.5) 0 0; font-size: ${cssVar('fontSizeSmall')}; color: ${cssVar('colorDanger')}; font-weight: ${cssVar('fontWeightStrong')}; }
|
|
26
|
+
|
|
27
|
+
.edv-control {
|
|
28
|
+
font: inherit;
|
|
29
|
+
color: inherit;
|
|
30
|
+
width: 100%;
|
|
31
|
+
min-height: ${cssVar('controlHeight')};
|
|
32
|
+
padding: 0 calc(${cssVar('space')} * 1.5);
|
|
33
|
+
background: ${cssVar('colorSurface')};
|
|
34
|
+
border: 1px solid ${cssVar('colorBorderStrong')};
|
|
35
|
+
border-radius: ${cssVar('radiusSmall')};
|
|
36
|
+
}
|
|
37
|
+
.edv-control:disabled { background: ${cssVar('colorSurfaceMuted')}; color: ${cssVar('colorTextMuted')}; cursor: not-allowed; }
|
|
38
|
+
.edv-control[aria-invalid='true'] { border-color: ${cssVar('colorDanger')}; }
|
|
39
|
+
|
|
40
|
+
.edv-button {
|
|
41
|
+
font: inherit;
|
|
42
|
+
font-weight: ${cssVar('fontWeightStrong')};
|
|
43
|
+
min-height: ${cssVar('controlHeight')};
|
|
44
|
+
padding: 0 calc(${cssVar('space')} * 2);
|
|
45
|
+
border-radius: ${cssVar('radiusSmall')};
|
|
46
|
+
border: 1px solid ${cssVar('colorPrimary')};
|
|
47
|
+
background: ${cssVar('colorPrimary')};
|
|
48
|
+
color: ${cssVar('colorOnPrimary')};
|
|
49
|
+
cursor: pointer;
|
|
50
|
+
white-space: nowrap;
|
|
51
|
+
}
|
|
52
|
+
.edv-button:hover:not(:disabled) { background: ${cssVar('colorPrimaryHover')}; border-color: ${cssVar('colorPrimaryHover')}; }
|
|
53
|
+
.edv-button:disabled { opacity: 0.55; cursor: not-allowed; }
|
|
54
|
+
.edv-button--secondary { background: transparent; color: ${cssVar('colorPrimary')}; }
|
|
55
|
+
.edv-button--secondary:hover:not(:disabled) { background: ${cssVar('colorHighlight')}; }
|
|
56
|
+
|
|
57
|
+
:focus-visible {
|
|
58
|
+
outline: ${cssVar('focusRingWidth')} solid ${cssVar('colorFocusRing')};
|
|
59
|
+
outline-offset: 2px;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.edv-visually-hidden {
|
|
63
|
+
position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px;
|
|
64
|
+
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@media (prefers-reduced-motion: reduce) {
|
|
68
|
+
* { transition: none !important; animation: none !important; }
|
|
69
|
+
}
|
|
70
|
+
`;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared design tokens for every Edvizion Astro component.
|
|
3
|
+
*
|
|
4
|
+
* Each token is a CSS custom property. Components read them with a built-in
|
|
5
|
+
* fallback (see `cssVar`), so they render correctly with no theme at all, and
|
|
6
|
+
* `<EdvizionTheme>` (or any stylesheet) overrides them by setting the property.
|
|
7
|
+
* Custom properties inherit through shadow roots, which is what lets one theme
|
|
8
|
+
* reach into every component's shadow DOM.
|
|
9
|
+
*/
|
|
10
|
+
export const TOKEN_PROPERTIES = {
|
|
11
|
+
fontFamily: '--edv-font-family',
|
|
12
|
+
fontSize: '--edv-font-size',
|
|
13
|
+
fontSizeSmall: '--edv-font-size-small',
|
|
14
|
+
fontWeightStrong: '--edv-font-weight-strong',
|
|
15
|
+
lineHeight: '--edv-line-height',
|
|
16
|
+
|
|
17
|
+
colorText: '--edv-color-text',
|
|
18
|
+
colorTextMuted: '--edv-color-text-muted',
|
|
19
|
+
colorSurface: '--edv-color-surface',
|
|
20
|
+
colorSurfaceMuted: '--edv-color-surface-muted',
|
|
21
|
+
colorBorder: '--edv-color-border',
|
|
22
|
+
colorBorderStrong: '--edv-color-border-strong',
|
|
23
|
+
colorPrimary: '--edv-color-primary',
|
|
24
|
+
colorPrimaryHover: '--edv-color-primary-hover',
|
|
25
|
+
colorOnPrimary: '--edv-color-on-primary',
|
|
26
|
+
colorHighlight: '--edv-color-highlight',
|
|
27
|
+
colorDanger: '--edv-color-danger',
|
|
28
|
+
colorFocusRing: '--edv-color-focus-ring',
|
|
29
|
+
|
|
30
|
+
radius: '--edv-radius',
|
|
31
|
+
radiusSmall: '--edv-radius-small',
|
|
32
|
+
space: '--edv-space',
|
|
33
|
+
controlHeight: '--edv-control-height',
|
|
34
|
+
focusRingWidth: '--edv-focus-ring-width',
|
|
35
|
+
shadow: '--edv-shadow',
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
export type ThemeToken = keyof typeof TOKEN_PROPERTIES;
|
|
39
|
+
export type ThemeTokens = Record<ThemeToken, string>;
|
|
40
|
+
|
|
41
|
+
export const DEFAULT_TOKENS: ThemeTokens = {
|
|
42
|
+
fontFamily: 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
|
|
43
|
+
fontSize: '1rem',
|
|
44
|
+
fontSizeSmall: '0.875rem',
|
|
45
|
+
fontWeightStrong: '600',
|
|
46
|
+
lineHeight: '1.5',
|
|
47
|
+
|
|
48
|
+
colorText: '#1f2937',
|
|
49
|
+
colorTextMuted: '#4b5563',
|
|
50
|
+
colorSurface: '#ffffff',
|
|
51
|
+
colorSurfaceMuted: '#f3f4f6',
|
|
52
|
+
colorBorder: '#d1d5db',
|
|
53
|
+
colorBorderStrong: '#6b7280',
|
|
54
|
+
colorPrimary: '#1d4ed8',
|
|
55
|
+
colorPrimaryHover: '#1e40af',
|
|
56
|
+
colorOnPrimary: '#ffffff',
|
|
57
|
+
colorHighlight: '#dbeafe',
|
|
58
|
+
colorDanger: '#b91c1c',
|
|
59
|
+
colorFocusRing: '#2563eb',
|
|
60
|
+
|
|
61
|
+
radius: '0.5rem',
|
|
62
|
+
radiusSmall: '0.375rem',
|
|
63
|
+
space: '0.5rem',
|
|
64
|
+
controlHeight: '2.5rem',
|
|
65
|
+
focusRingWidth: '3px',
|
|
66
|
+
shadow: '0 1px 2px rgb(0 0 0 / 0.06), 0 1px 3px rgb(0 0 0 / 0.1)',
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** `var(--edv-x, <light default>)` — use this in component styles. */
|
|
70
|
+
export function cssVar(token: ThemeToken): string {
|
|
71
|
+
return `var(${TOKEN_PROPERTIES[token]}, ${DEFAULT_TOKENS[token]})`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Serialises tokens as declarations, e.g. `--edv-radius: 0.5rem;`. */
|
|
75
|
+
export function tokenDeclarations(tokens: Partial<ThemeTokens>): string {
|
|
76
|
+
return (Object.keys(tokens) as ThemeToken[])
|
|
77
|
+
.filter((key) => key in TOKEN_PROPERTIES && tokens[key] != null)
|
|
78
|
+
.map((key) => `${TOKEN_PROPERTIES[key]}: ${sanitizeValue(String(tokens[key]))};`)
|
|
79
|
+
.join(' ');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Values are written into a <style> element, so they must not be able to
|
|
83
|
+
// close it or open a new rule.
|
|
84
|
+
function sanitizeValue(value: string): string {
|
|
85
|
+
return value.replace(/[<>{};]/g, '');
|
|
86
|
+
}
|