@danilosimonatto/ionicons-minimal-weather-widget 0.1.1 → 0.2.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 CHANGED
@@ -29,6 +29,27 @@ You can then use the custom element in your HTML:
29
29
  ></weather-widget>
30
30
  ```
31
31
 
32
+ ## Astro
33
+
34
+ Astro frontmatter runs on the server/build step, but this package registers a **browser custom element** (it uses `window`, `HTMLElement`, and `customElements`). That means the widget must still be loaded **client-side**.
35
+
36
+ To make this easy, the package ships an Astro wrapper component that includes the needed module import for you:
37
+
38
+ ```astro
39
+ ---
40
+ import WeatherWidget from "@danilosimonatto/ionicons-minimal-weather-widget/astro";
41
+ ---
42
+
43
+ <WeatherWidget city="Milan" apiKey={import.meta.env.PUBLIC_OPENWEATHER_API_KEY} />
44
+ ```
45
+
46
+ Props:
47
+
48
+ - `city` (string, required)
49
+ - `apiKey` (string, required)
50
+ - `scale` ("C" | "F", optional, default "C")
51
+ - `iconStyle` ("filled" | "outline" | "sharp", optional, default "filled")
52
+
32
53
  ## Options
33
54
 
34
55
  | Attribute | Type | Description | Example |
@@ -56,6 +77,26 @@ You can then use the custom element in your HTML:
56
77
  - 🎨 Customizable icon style and units
57
78
  - 🔐 Uses your OpenWeather API key via the `api-key` attribute
58
79
 
80
+ ## Development (editing HTML/CSS with formatting)
81
+
82
+ The published widget bundle is **generated** so consumers can keep importing a single file.
83
+
84
+ - Edit:
85
+ - `src/styles.css`
86
+ - `src/template.html`
87
+ - `src/weather-widget.vite.js`
88
+ - Then run:
89
+
90
+ ```sh
91
+ npm run build
92
+ ```
93
+
94
+ For auto-rebuild while developing (e.g. with `npm link`), run:
95
+
96
+ ```sh
97
+ npm run dev
98
+ ```
99
+
59
100
  ## License
60
101
 
61
102
  MIT
@@ -0,0 +1,31 @@
1
+ ---
2
+ type Props = {
3
+ city: string;
4
+ scale?: "C" | "F";
5
+ iconStyle?: "filled" | "outline" | "sharp";
6
+ apiKey: string;
7
+ };
8
+
9
+ const {
10
+ city,
11
+ scale = "C",
12
+ iconStyle = "filled",
13
+ apiKey,
14
+ } = Astro.props as Props;
15
+ ---
16
+
17
+ <weather-widget
18
+ city={city}
19
+ scale={scale}
20
+ icon-style={iconStyle}
21
+ api-key={apiKey}
22
+ ></weather-widget>
23
+
24
+ <!--
25
+ This package defines a browser custom element, so it must be loaded client-side.
26
+ By placing this import in an inline module script, Astro/Vite will bundle it and
27
+ run it in the browser without the user needing a separate script file.
28
+ -->
29
+ <script type="module">
30
+ import "../dist/weather-widget.js";
31
+ </script>
@@ -0,0 +1,241 @@
1
+ import { defineCustomElements as f } from "ionicons/loader";
2
+ import { addIcons as w } from "ionicons";
3
+ import { helpCircleSharp as b, helpCircleOutline as S, helpCircle as x, snowSharp as C, snowOutline as O, snow as E, thunderstormSharp as k, thunderstormOutline as I, thunderstorm as v, rainySharp as A, rainyOutline as P, rainy as q, cloudyNightSharp as M, cloudyNightOutline as N, cloudyNight as U, cloudySharp as z, cloudyOutline as L, cloudy as $, partlySunnySharp as F, partlySunnyOutline as R, partlySunny as T, moonSharp as W, moonOutline as j, moon as K, sunnySharp as _, sunnyOutline as D, sunny as H } from "ionicons/icons";
4
+ const B = `:host {
5
+ display: inline-block;
6
+ }
7
+
8
+ .weather-widget {
9
+ display: inline-flex;
10
+ flex-direction: column;
11
+ gap: 8px;
12
+ padding: 1.5rem;
13
+ background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
14
+ border-radius: 16px;
15
+ color: white;
16
+ text-align: center;
17
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
18
+ min-width: 250px;
19
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
20
+ Oxygen, Ubuntu, Cantarell, sans-serif;
21
+ }
22
+
23
+ .loading,
24
+ .error {
25
+ font-size: 1rem;
26
+ padding: 1rem;
27
+ }
28
+
29
+ .error {
30
+ color: #ffebee;
31
+ background: rgba(255, 0, 0, 0.2);
32
+ border-radius: 8px;
33
+ }
34
+
35
+ .weather-content {
36
+ display: flex;
37
+ flex-direction: row;
38
+ gap: 12px;
39
+ font-size: 2.5rem;
40
+ font-weight: 700;
41
+ align-items: center;
42
+ justify-content: center;
43
+ }
44
+
45
+ .city-name {
46
+ font-size: 1.5rem;
47
+ font-weight: 600;
48
+ text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
49
+ }
50
+
51
+ .icon {
52
+ font-size: 3rem;
53
+ text-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
54
+ }
55
+
56
+ .temperature {
57
+ text-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
58
+ }
59
+ `, J = `<div
60
+ class="weather-widget"
61
+ part="root"
62
+ >
63
+ <div
64
+ class="loading"
65
+ part="loading"
66
+ >
67
+ Loading…
68
+ </div>
69
+
70
+ <div
71
+ class="weather-content"
72
+ part="content"
73
+ hidden
74
+ >
75
+ <div
76
+ class="city-name"
77
+ part="city"
78
+ ></div>
79
+ <ion-icon
80
+ class="icon"
81
+ aria-hidden="true"
82
+ ></ion-icon>
83
+ <span
84
+ class="temperature"
85
+ part="temperature"
86
+ ></span>
87
+ </div>
88
+
89
+ <div
90
+ class="error"
91
+ part="error"
92
+ hidden
93
+ ></div>
94
+ </div>
95
+ `;
96
+ let a;
97
+ function G() {
98
+ return a || (w({
99
+ sunny: H,
100
+ sunnyOutline: D,
101
+ sunnySharp: _,
102
+ moon: K,
103
+ moonOutline: j,
104
+ moonSharp: W,
105
+ partlySunny: T,
106
+ partlySunnyOutline: R,
107
+ partlySunnySharp: F,
108
+ cloudy: $,
109
+ cloudyOutline: L,
110
+ cloudySharp: z,
111
+ cloudyNight: U,
112
+ cloudyNightOutline: N,
113
+ cloudyNightSharp: M,
114
+ rainy: q,
115
+ rainyOutline: P,
116
+ rainySharp: A,
117
+ thunderstorm: v,
118
+ thunderstormOutline: I,
119
+ thunderstormSharp: k,
120
+ snow: E,
121
+ snowOutline: O,
122
+ snowSharp: C,
123
+ helpCircle: x,
124
+ helpCircleOutline: S,
125
+ helpCircleSharp: b
126
+ }), a = f(window)), a;
127
+ }
128
+ const Q = (n) => String(n || "").toUpperCase() === "F" ? "F" : "C", V = (n) => n === "outline" || n === "sharp" || n === "filled" ? n : "filled", X = (n, t) => t === "filled" ? n : `${n}-${t}`, Y = {
129
+ // 01d - sunny
130
+ "01d": "sunny",
131
+ "01n": "moon",
132
+ // 02d - partly-sunny
133
+ "02d": "partly-sunny",
134
+ "02n": "cloudy-night",
135
+ // 03d, 04d, 50d - cloudy
136
+ "03d": "cloudy",
137
+ "03n": "cloudy",
138
+ "04d": "cloudy",
139
+ "04n": "cloudy",
140
+ "50d": "cloudy",
141
+ "50n": "cloudy",
142
+ // 09d, 10d - rainy
143
+ "09d": "rainy",
144
+ "09n": "rainy",
145
+ "10d": "rainy",
146
+ "10n": "rainy",
147
+ // 11d - thunderstorm
148
+ "11d": "thunderstorm",
149
+ "11n": "thunderstorm",
150
+ // 13d - snow
151
+ "13d": "snow",
152
+ "13n": "snow"
153
+ };
154
+ class Z extends HTMLElement {
155
+ static observedAttributes = ["city", "scale", "icon-style", "api-key"];
156
+ #n = null;
157
+ #e = this.attachShadow({ mode: "open" });
158
+ #t = null;
159
+ connectedCallback() {
160
+ G().then(() => {
161
+ this.#o(), this.#i();
162
+ });
163
+ }
164
+ disconnectedCallback() {
165
+ this.#n && this.#n.abort();
166
+ }
167
+ attributeChangedCallback() {
168
+ this.isConnected && this.#i();
169
+ }
170
+ get city() {
171
+ return (this.getAttribute("city") || "").trim();
172
+ }
173
+ get scale() {
174
+ return Q(this.getAttribute("scale"));
175
+ }
176
+ get iconStyle() {
177
+ return V(this.getAttribute("icon-style"));
178
+ }
179
+ get apiKey() {
180
+ return (this.getAttribute("api-key") || "").trim();
181
+ }
182
+ #o() {
183
+ this.#e.innerHTML = `<style>${B}</style>${J}`;
184
+ const t = this.#e.querySelector(".weather-widget");
185
+ t && (this.#t = {
186
+ root: t,
187
+ loading: t.querySelector('[part="loading"]'),
188
+ content: t.querySelector('[part="content"]'),
189
+ city: t.querySelector('[part="city"]'),
190
+ icon: t.querySelector("ion-icon"),
191
+ temp: t.querySelector('[part="temperature"]'),
192
+ error: t.querySelector('[part="error"]')
193
+ });
194
+ }
195
+ #s() {
196
+ this.#t && (this.#t.loading.hidden = !1, this.#t.content.hidden = !0, this.#t.error.hidden = !0);
197
+ }
198
+ #r(t) {
199
+ this.#t && (this.#t.loading.hidden = !0, this.#t.content.hidden = !0, this.#t.error.hidden = !1, this.#t.error.textContent = t);
200
+ }
201
+ #a({ cityName: t, iconName: e, tempText: r }) {
202
+ this.#t && (this.#t.loading.hidden = !0, this.#t.error.hidden = !0, this.#t.content.hidden = !1, this.#t.city.textContent = t, this.#t.icon.setAttribute("name", e), this.#t.temp.textContent = r);
203
+ }
204
+ async #i() {
205
+ const t = this.city;
206
+ if (!t) {
207
+ this.#r("Missing city");
208
+ return;
209
+ }
210
+ this.#n && this.#n.abort(), this.#n = new AbortController(), this.#s();
211
+ try {
212
+ const e = this.apiKey;
213
+ if (!e) throw new Error("Missing API key");
214
+ const r = new URL("https://api.openweathermap.org/geo/1.0/direct");
215
+ r.searchParams.set("q", t), r.searchParams.set("limit", "1"), r.searchParams.set("appid", e);
216
+ const c = await fetch(r, { signal: this.#n.signal });
217
+ if (!c.ok) throw new Error("City not found");
218
+ const l = await c.json(), o = l && l[0];
219
+ if (!o) throw new Error("City not found");
220
+ const i = new URL(
221
+ "https://api.openweathermap.org/data/2.5/weather"
222
+ );
223
+ i.searchParams.set("lat", String(o.lat)), i.searchParams.set("lon", String(o.lon)), i.searchParams.set("units", "metric"), i.searchParams.set("appid", e);
224
+ const h = await fetch(i, {
225
+ signal: this.#n.signal
226
+ });
227
+ if (!h.ok) throw new Error("Weather not available");
228
+ const s = await h.json(), d = s?.main?.temp, p = this.scale === "F" ? Math.round(d * 9 / 5 + 32) : Math.round(d), u = s?.weather?.[0]?.icon, y = u && Y[u] || "help-circle", m = X(y, this.iconStyle), g = s?.name || t;
229
+ this.#a({
230
+ cityName: g,
231
+ iconName: m,
232
+ tempText: `${p}°${this.scale}`
233
+ });
234
+ } catch (e) {
235
+ if (e instanceof DOMException && e.name === "AbortError") return;
236
+ const r = e instanceof Error ? e.message : "Failed to fetch weather";
237
+ this.#r(r);
238
+ }
239
+ }
240
+ }
241
+ customElements.get("weather-widget") || customElements.define("weather-widget", Z);
package/package.json CHANGED
@@ -1,22 +1,34 @@
1
1
  {
2
2
  "name": "@danilosimonatto/ionicons-minimal-weather-widget",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "author": "Danilo Simonatto",
7
+ "scripts": {
8
+ "build": "vite build",
9
+ "dev": "vite build --watch",
10
+ "prepack": "vite build"
11
+ },
7
12
  "files": [
8
- "weather-widget.js",
13
+ "dist",
9
14
  "weather-widget.d.ts",
15
+ "astro",
16
+ "src",
17
+ "vite.config.js",
10
18
  "README.md",
11
19
  "LICENSE"
12
20
  ],
13
21
  "exports": {
14
22
  ".": {
15
23
  "types": "./weather-widget.d.ts",
16
- "default": "./weather-widget.js"
17
- }
24
+ "default": "./dist/weather-widget.js"
25
+ },
26
+ "./astro": "./astro/WeatherWidget.astro"
18
27
  },
19
28
  "dependencies": {
20
29
  "ionicons": "^8.0.13"
30
+ },
31
+ "devDependencies": {
32
+ "vite": "^7.3.1"
21
33
  }
22
34
  }
package/src/styles.css ADDED
@@ -0,0 +1,55 @@
1
+ :host {
2
+ display: inline-block;
3
+ }
4
+
5
+ .weather-widget {
6
+ display: inline-flex;
7
+ flex-direction: column;
8
+ gap: 8px;
9
+ padding: 1.5rem;
10
+ background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
11
+ border-radius: 16px;
12
+ color: white;
13
+ text-align: center;
14
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
15
+ min-width: 250px;
16
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
17
+ Oxygen, Ubuntu, Cantarell, sans-serif;
18
+ }
19
+
20
+ .loading,
21
+ .error {
22
+ font-size: 1rem;
23
+ padding: 1rem;
24
+ }
25
+
26
+ .error {
27
+ color: #ffebee;
28
+ background: rgba(255, 0, 0, 0.2);
29
+ border-radius: 8px;
30
+ }
31
+
32
+ .weather-content {
33
+ display: flex;
34
+ flex-direction: row;
35
+ gap: 12px;
36
+ font-size: 2.5rem;
37
+ font-weight: 700;
38
+ align-items: center;
39
+ justify-content: center;
40
+ }
41
+
42
+ .city-name {
43
+ font-size: 1.5rem;
44
+ font-weight: 600;
45
+ text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
46
+ }
47
+
48
+ .icon {
49
+ font-size: 3rem;
50
+ text-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
51
+ }
52
+
53
+ .temperature {
54
+ text-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
55
+ }
@@ -0,0 +1,36 @@
1
+ <div
2
+ class="weather-widget"
3
+ part="root"
4
+ >
5
+ <div
6
+ class="loading"
7
+ part="loading"
8
+ >
9
+ Loading…
10
+ </div>
11
+
12
+ <div
13
+ class="weather-content"
14
+ part="content"
15
+ hidden
16
+ >
17
+ <div
18
+ class="city-name"
19
+ part="city"
20
+ ></div>
21
+ <ion-icon
22
+ class="icon"
23
+ aria-hidden="true"
24
+ ></ion-icon>
25
+ <span
26
+ class="temperature"
27
+ part="temperature"
28
+ ></span>
29
+ </div>
30
+
31
+ <div
32
+ class="error"
33
+ part="error"
34
+ hidden
35
+ ></div>
36
+ </div>
@@ -0,0 +1,4 @@
1
+ // Vite entry: pulls in the source module that defines the <weather-widget> custom element.
2
+ import "./weather-widget.vite.js";
3
+
4
+
@@ -1,3 +1,6 @@
1
+ import styles from "./styles.css?raw";
2
+ import template from "./template.html?raw";
3
+
1
4
  import { defineCustomElements } from "ionicons/loader";
2
5
  import { addIcons } from "ionicons";
3
6
  import {
@@ -114,20 +117,12 @@ const ICON_CODE_TO_IONICON = {
114
117
  "13n": "snow",
115
118
  };
116
119
 
117
- const escapeHtml = (input) => {
118
- return String(input)
119
- .replaceAll("&", "&amp;")
120
- .replaceAll("<", "&lt;")
121
- .replaceAll(">", "&gt;")
122
- .replaceAll('"', "&quot;")
123
- .replaceAll("'", "&#039;");
124
- };
125
-
126
120
  class WeatherWidgetElement extends HTMLElement {
127
121
  static observedAttributes = ["city", "scale", "icon-style", "api-key"];
128
122
 
129
123
  #abort = null;
130
124
  #shadow = this.attachShadow({ mode: "open" });
125
+ #els = null;
131
126
 
132
127
  connectedCallback() {
133
128
  ensureIonicons().then(() => {
@@ -162,89 +157,61 @@ class WeatherWidgetElement extends HTMLElement {
162
157
  }
163
158
 
164
159
  #renderShell() {
165
- this.#shadow.innerHTML = `
166
- <style>
167
- :host {
168
- display: inline-block;
169
- }
170
- .weather-widget {
171
- display: inline-flex;
172
- flex-direction: column;
173
- gap: 8px;
174
- padding: 1.5rem;
175
- background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
176
- border-radius: 16px;
177
- color: white;
178
- text-align: center;
179
- box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
180
- min-width: 250px;
181
- font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
182
- }
183
- .loading,
184
- .error {
185
- font-size: 1rem;
186
- padding: 1rem;
187
- }
188
- .error {
189
- color: #ffebee;
190
- background: rgba(255, 0, 0, 0.2);
191
- border-radius: 8px;
192
- }
193
- .weather-content {
194
- display: flex;
195
- flex-direction: column;
196
- gap: 12px;
197
- }
198
- .city-name {
199
- font-size: 1.5rem;
200
- font-weight: 600;
201
- text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
202
- }
203
- .weather {
204
- display: flex;
205
- gap: 12px;
206
- align-items: center;
207
- justify-content: center;
208
- font-size: 2.5rem;
209
- font-weight: 700;
210
- }
211
- .icon {
212
- font-size: 3rem;
213
- text-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
214
- }
215
- .temperature {
216
- text-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
217
- }
218
- </style>
219
- <div class="weather-widget" part="root">
220
- <div class="loading" part="loading">Loading…</div>
221
- </div>
222
- `;
223
- }
224
-
225
- #setInner(html) {
160
+ this.#shadow.innerHTML = `<style>${styles}</style>${template}`;
226
161
  const root = this.#shadow.querySelector(".weather-widget");
227
162
  if (!root) return;
228
- root.innerHTML = html;
163
+
164
+ this.#els = {
165
+ root,
166
+ loading: root.querySelector('[part="loading"]'),
167
+ content: root.querySelector('[part="content"]'),
168
+ city: root.querySelector('[part="city"]'),
169
+ icon: root.querySelector("ion-icon"),
170
+ temp: root.querySelector('[part="temperature"]'),
171
+ error: root.querySelector('[part="error"]'),
172
+ };
173
+ }
174
+
175
+ #showLoading() {
176
+ if (!this.#els) return;
177
+ this.#els.loading.hidden = false;
178
+ this.#els.content.hidden = true;
179
+ this.#els.error.hidden = true;
180
+ }
181
+
182
+ #showError(message) {
183
+ if (!this.#els) return;
184
+ this.#els.loading.hidden = true;
185
+ this.#els.content.hidden = true;
186
+ this.#els.error.hidden = false;
187
+ this.#els.error.textContent = message;
188
+ }
189
+
190
+ #showWeather({ cityName, iconName, tempText }) {
191
+ if (!this.#els) return;
192
+ this.#els.loading.hidden = true;
193
+ this.#els.error.hidden = true;
194
+ this.#els.content.hidden = false;
195
+
196
+ // Use textContent for safety; avoid innerHTML for dynamic content.
197
+ this.#els.city.textContent = cityName;
198
+ this.#els.icon.setAttribute("name", iconName);
199
+ this.#els.temp.textContent = tempText;
229
200
  }
230
201
 
231
202
  async #load() {
232
203
  const city = this.city;
233
204
  if (!city) {
234
- this.#setInner(`<div class="error" part="error">Missing city</div>`);
205
+ this.#showError("Missing city");
235
206
  return;
236
207
  }
237
208
 
238
209
  if (this.#abort) this.#abort.abort();
239
210
  this.#abort = new AbortController();
240
211
 
241
- this.#setInner(`<div class="loading" part="loading">Loading…</div>`);
212
+ this.#showLoading();
242
213
 
243
214
  try {
244
- // Mirrors the old Vue component's behavior:
245
- // - OpenWeatherMap geocoding -> lat/lon
246
- // - OpenWeatherMap weather in metric
247
- // - Convert to Fahrenheit client-side if needed
248
215
  const apiKey = this.apiKey;
249
216
  if (!apiKey) throw new Error("Missing API key");
250
217
 
@@ -288,22 +255,16 @@ class WeatherWidgetElement extends HTMLElement {
288
255
 
289
256
  const cityName = weather?.name || city;
290
257
 
291
- this.#setInner(`
292
- <div class="weather-content" part="content">
293
- <div class="city-name" part="city">${escapeHtml(cityName)}</div>
294
- <div class="weather" part="weather">
295
- <ion-icon name="${iconName}" class="icon" aria-hidden="true"></ion-icon>
296
- <span class="temperature" part="temperature">${temp}°${this.scale}</span>
297
- </div>
298
- </div>
299
- `);
258
+ this.#showWeather({
259
+ cityName,
260
+ iconName,
261
+ tempText: `${temp}°${this.scale}`,
262
+ });
300
263
  } catch (e) {
301
264
  if (e instanceof DOMException && e.name === "AbortError") return;
302
265
  const message =
303
266
  e instanceof Error ? e.message : "Failed to fetch weather";
304
- this.#setInner(
305
- `<div class="error" part="error">${escapeHtml(message)}</div>`
306
- );
267
+ this.#showError(message);
307
268
  }
308
269
  }
309
270
  }
package/vite.config.js ADDED
@@ -0,0 +1,20 @@
1
+ import { defineConfig } from "vite";
2
+ import path from "node:path";
3
+
4
+ export default defineConfig({
5
+ build: {
6
+ // Output into dist/ to avoid overwriting source files.
7
+ outDir: "dist",
8
+ emptyOutDir: true,
9
+ lib: {
10
+ entry: path.resolve(__dirname, "src/weather-widget.entry.js"),
11
+ formats: ["es"],
12
+ fileName: () => "weather-widget.js",
13
+ },
14
+ rollupOptions: {
15
+ // Keep dependency behavior identical to the hand-written module:
16
+ // consumers will still resolve `ionicons` from this package's dependencies.
17
+ external: (id) => id === "ionicons" || id.startsWith("ionicons/"),
18
+ },
19
+ },
20
+ });