@sondor-technology/sondor-components 1.0.2 → 1.0.4

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 (40) hide show
  1. package/dist/components/checkbox/checkbox.d.ts +3 -0
  2. package/dist/components/checkbox/checkbox.d.ts.map +1 -1
  3. package/dist/components/checkbox/checkbox.js +38 -20
  4. package/dist/components/form/form-step.d.ts +1 -0
  5. package/dist/components/form/form-step.d.ts.map +1 -1
  6. package/dist/components/form/form-step.js +3 -0
  7. package/dist/components/form/form.d.ts +7 -2
  8. package/dist/components/form/form.d.ts.map +1 -1
  9. package/dist/components/form/form.js +157 -59
  10. package/dist/components/icon/icon-paths.d.ts +16 -0
  11. package/dist/components/icon/icon-paths.d.ts.map +1 -1
  12. package/dist/components/icon/icon-paths.js +16 -0
  13. package/dist/components/icon/icon.d.ts +2 -1
  14. package/dist/components/icon/icon.d.ts.map +1 -1
  15. package/dist/components/modal/modal.d.ts +11 -0
  16. package/dist/components/modal/modal.d.ts.map +1 -1
  17. package/dist/components/modal/modal.js +103 -15
  18. package/dist/components/router/router.d.ts +27 -3
  19. package/dist/components/router/router.d.ts.map +1 -1
  20. package/dist/components/router/router.js +72 -12
  21. package/dist/components/skeleton/skeleton.d.ts +2 -3
  22. package/dist/components/skeleton/skeleton.d.ts.map +1 -1
  23. package/dist/components/skeleton/skeleton.js +89 -18
  24. package/dist/components/vertical-nav/vertical-nav-group.d.ts +6 -0
  25. package/dist/components/vertical-nav/vertical-nav-group.d.ts.map +1 -1
  26. package/dist/components/vertical-nav/vertical-nav-group.js +37 -21
  27. package/dist/components/vertical-nav/vertical-nav-item.d.ts +6 -0
  28. package/dist/components/vertical-nav/vertical-nav-item.d.ts.map +1 -1
  29. package/dist/components/vertical-nav/vertical-nav-item.js +27 -7
  30. package/package.json +5 -1
  31. package/src/components/checkbox/checkbox.ts +54 -26
  32. package/src/components/form/form-step.ts +5 -1
  33. package/src/components/form/form.ts +182 -66
  34. package/src/components/icon/icon-paths.ts +16 -0
  35. package/src/components/icon/icon.ts +3 -1
  36. package/src/components/modal/modal.ts +103 -15
  37. package/src/components/router/router.ts +99 -12
  38. package/src/components/skeleton/skeleton.ts +87 -20
  39. package/src/components/vertical-nav/vertical-nav-group.ts +40 -21
  40. package/src/components/vertical-nav/vertical-nav-item.ts +30 -7
@@ -1,5 +1,5 @@
1
- import {LitElement, html, css} from "lit";
2
- import {customElement, property, state, queryAssignedElements, query} from "lit/decorators.js";
1
+ import {LitElement, html, css, PropertyValues} from "lit";
2
+ import {customElement, property, state, query} from "lit/decorators.js";
3
3
  import {resetStyles} from "../../styles/reset.js";
4
4
  import type {StFormStep} from "./form-step.js";
5
5
 
@@ -13,11 +13,14 @@ export class StForm extends LitElement {
13
13
 
14
14
  @state() private _currentStepIndex = 0;
15
15
 
16
- @queryAssignedElements({selector: "st-form-step"})
17
- private _stepElements!: StFormStep[];
18
-
19
16
  @query("form") private _formEl!: HTMLFormElement;
20
17
 
18
+ private _modalFooterEl: HTMLElement | null = null;
19
+
20
+ private get _steps(): StFormStep[] {
21
+ return Array.from(this.querySelectorAll("st-form-step"));
22
+ }
23
+
21
24
  static styles = [
22
25
  resetStyles,
23
26
  css`
@@ -31,9 +34,6 @@ export class StForm extends LitElement {
31
34
  gap: var(--st-spacing-md);
32
35
  }
33
36
 
34
- /* ==========================================================
35
- Layouts
36
- ========================================================== */
37
37
  :host([layout="vertical"]) form {
38
38
  flex-direction: column;
39
39
  }
@@ -55,9 +55,6 @@ export class StForm extends LitElement {
55
55
  flex-direction: column;
56
56
  }
57
57
 
58
- /* ==========================================================
59
- Progress Navigation
60
- ========================================================== */
61
58
  .progress-nav {
62
59
  display: flex;
63
60
  align-items: center;
@@ -121,9 +118,6 @@ export class StForm extends LitElement {
121
118
  background-color: var(--st-color-success);
122
119
  }
123
120
 
124
- /* ==========================================================
125
- Actions & States
126
- ========================================================== */
127
121
  .form-actions {
128
122
  display: flex;
129
123
  gap: var(--st-spacing-md);
@@ -162,42 +156,165 @@ export class StForm extends LitElement {
162
156
  `,
163
157
  ];
164
158
 
165
- private _handleSlotChange() {
166
- if (this._stepElements.length > 0) {
159
+ override connectedCallback() {
160
+ super.connectedCallback();
161
+ this.updateComplete.then(() => {
167
162
  this._updateStepVisibility();
163
+ this._updateModalFooter();
164
+ });
165
+ }
166
+
167
+ override disconnectedCallback() {
168
+ super.disconnectedCallback();
169
+ if (this._modalFooterEl) {
170
+ this._modalFooterEl.remove();
171
+ this._modalFooterEl = null;
172
+ }
173
+ }
174
+
175
+ override updated(changedProperties: PropertyValues<this>) {
176
+ super.updated(changedProperties);
177
+ this._updateModalFooter();
178
+ }
179
+
180
+ private _updateModalFooter() {
181
+ const modal = this.closest("st-modal");
182
+ if (modal) {
183
+ if (!this._modalFooterEl) {
184
+ this._modalFooterEl = document.createElement("div");
185
+ this._modalFooterEl.setAttribute("slot", "footer");
186
+ this._modalFooterEl.style.display = "flex";
187
+ this._modalFooterEl.style.gap = "var(--st-spacing-md)";
188
+ this._modalFooterEl.style.width = "100%";
189
+ this._modalFooterEl.style.justifyContent = "flex-end";
190
+ this._modalFooterEl.style.alignItems = "center";
191
+ modal.appendChild(this._modalFooterEl);
192
+ }
193
+
194
+ const steps = this._steps;
195
+ const hasSteps = steps.length > 0;
196
+ const isLastStep = hasSteps ? this._currentStepIndex === steps.length - 1 : true;
197
+
198
+ // Imperative DOM management prevents event listener loss and re-rendering conflicts
199
+ this._modalFooterEl.innerHTML = "";
200
+
201
+ if (hasSteps && this._currentStepIndex > 0) {
202
+ const backBtn = document.createElement("st-button");
203
+ backBtn.setAttribute("variant", "secondary");
204
+ backBtn.setAttribute("type", "button");
205
+ if (this.loading) backBtn.setAttribute("disabled", "");
206
+ backBtn.textContent = "Back";
207
+ backBtn.addEventListener("click", (e) => {
208
+ e.preventDefault();
209
+ this._prevStep();
210
+ });
211
+ this._modalFooterEl.appendChild(backBtn);
212
+ }
213
+
214
+ const submitBtn = document.createElement("st-button");
215
+ submitBtn.setAttribute("color", "primary");
216
+ submitBtn.setAttribute("type", "button");
217
+ if (this.loading) submitBtn.setAttribute("disabled", "");
218
+ submitBtn.textContent = this.loading ? "Submitting..." : (isLastStep ? "Submit" : "Continue");
219
+ submitBtn.addEventListener("click", (e) => {
220
+ e.preventDefault();
221
+ this._triggerSubmission();
222
+ });
223
+ this._modalFooterEl.appendChild(submitBtn);
224
+
225
+ } else if (this._modalFooterEl) {
226
+ this._modalFooterEl.remove();
227
+ this._modalFooterEl = null;
168
228
  }
169
229
  }
170
230
 
231
+ private _handleSlotChange() {
232
+ this._updateStepVisibility();
233
+ this._updateModalFooter();
234
+ }
235
+
171
236
  private _updateStepVisibility() {
172
- this._stepElements.forEach((step, index) => {
237
+ const steps = this._steps;
238
+ steps.forEach((step, index) => {
173
239
  step.active = index === this._currentStepIndex;
174
240
  });
175
241
  this.requestUpdate();
176
242
  }
177
243
 
178
- private _validateCurrentContext(): boolean {
179
- if (this._stepElements.length === 0) {
180
- return this._formEl.checkValidity();
181
- }
244
+ private async _validateCurrentContext(): Promise<boolean> {
245
+ const steps = this._steps;
246
+ const scope: ParentNode = steps.length > 0
247
+ ? steps[this._currentStepIndex]
248
+ : this;
182
249
 
183
- const currentStep = this._stepElements[this._currentStepIndex];
184
250
  let isValid = true;
185
251
 
186
- // Find all custom and native form inputs assigned to the current step
187
252
  const inputs = Array.from(
188
- currentStep.querySelectorAll<
189
- HTMLElement & {checkValidity?: () => boolean; reportValidity?: () => void}
190
- >("*"),
253
+ scope.querySelectorAll<
254
+ HTMLElement & {
255
+ checkValidity?: () => boolean;
256
+ reportValidity?: () => boolean;
257
+ required?: boolean;
258
+ value?: any;
259
+ checked?: boolean;
260
+ invalid?: boolean;
261
+ }
262
+ >("st-input, st-select, st-textarea, st-checkbox, st-radio, input, select, textarea"),
191
263
  );
192
-
264
+
193
265
  for (const input of inputs) {
266
+ if (input.hasAttribute("disabled") || (input as any).disabled) continue;
267
+
268
+ let fieldValid = true;
269
+
194
270
  if (typeof input.checkValidity === "function") {
195
- if (!input.checkValidity()) {
196
- // Focus and report on the first invalid element encountered
197
- if (isValid && typeof input.reportValidity === "function") {
198
- input.reportValidity();
271
+ fieldValid = input.checkValidity();
272
+ } else {
273
+ const isRequired = input.hasAttribute("required") || input.required;
274
+ if (isRequired) {
275
+ const isToggle = input.tagName === "ST-CHECKBOX" || input.tagName === "ST-RADIO";
276
+ const val = isToggle ? input.checked : input.value;
277
+ if (val === undefined || val === null || val === "" || val === false) {
278
+ fieldValid = false;
199
279
  }
280
+ }
281
+ }
282
+
283
+ if (!fieldValid) {
284
+ if (typeof input.reportValidity === "function") {
285
+ input.reportValidity();
286
+ } else if ("invalid" in input) {
287
+ input.invalid = true;
288
+ }
289
+ if (isValid && typeof (input as any).focus === "function") {
290
+ (input as any).focus();
291
+ }
292
+ isValid = false;
293
+ }
294
+ }
295
+
296
+ if (steps.length === 0 && this._formEl) {
297
+ if (!this._formEl.checkValidity()) {
298
+ isValid = false;
299
+ }
300
+ }
301
+
302
+ if (isValid && steps.length > 0) {
303
+ const currentStep = steps[this._currentStepIndex] as StFormStep & {
304
+ validate?: () => Promise<boolean> | boolean;
305
+ };
306
+
307
+ if (typeof currentStep.validate === "function") {
308
+ try {
309
+ this.loading = true;
310
+ const stepPassed = await currentStep.validate();
311
+ if (!stepPassed) {
312
+ isValid = false;
313
+ }
314
+ } catch {
200
315
  isValid = false;
316
+ } finally {
317
+ this.loading = false;
201
318
  }
202
319
  }
203
320
  }
@@ -206,7 +323,6 @@ export class StForm extends LitElement {
206
323
  }
207
324
 
208
325
  private _triggerSubmission() {
209
- // HTML5 method that triggers form validation and fires the native submit event
210
326
  this._formEl.requestSubmit();
211
327
  }
212
328
 
@@ -220,13 +336,11 @@ export class StForm extends LitElement {
220
336
  private _extractFormData(): FormData {
221
337
  const formData = new FormData();
222
338
 
223
- // Query all light-DOM children that have a 'name' attribute
224
339
  const fields = this.querySelectorAll<
225
340
  HTMLElement & {name: string; value: any; checked?: boolean; files?: FileList}
226
341
  >("[name]");
227
342
 
228
343
  fields.forEach((field) => {
229
- // Standard HTML form behavior: ignore disabled fields
230
344
  if (field.hasAttribute("disabled")) return;
231
345
 
232
346
  const isToggle =
@@ -236,7 +350,6 @@ export class StForm extends LitElement {
236
350
  field.getAttribute("type") === "checkbox";
237
351
 
238
352
  if (isToggle) {
239
- // Only append radios/checkboxes if they are actively checked
240
353
  if (field.checked) {
241
354
  formData.append(field.name, field.value || "on");
242
355
  }
@@ -245,10 +358,8 @@ export class StForm extends LitElement {
245
358
  field.getAttribute("type") === "file" &&
246
359
  field.files
247
360
  ) {
248
- // Handle native file uploads
249
361
  Array.from(field.files).forEach((file) => formData.append(field.name, file));
250
362
  } else if (field.value !== undefined) {
251
- // Handle st-input, st-select, st-textarea, and standard inputs
252
363
  formData.append(field.name, field.value);
253
364
  }
254
365
  });
@@ -256,14 +367,16 @@ export class StForm extends LitElement {
256
367
  return formData;
257
368
  }
258
369
 
259
- private _handleSubmit(e: Event) {
370
+ private async _handleSubmit(e: Event) {
260
371
  e.preventDefault();
261
372
  e.stopPropagation();
262
373
 
263
- if (!this._validateCurrentContext()) return;
374
+ const isValid = await this._validateCurrentContext();
375
+ if (!isValid) return;
264
376
 
265
- const hasSteps = this._stepElements.length > 0;
266
- const isLastStep = hasSteps ? this._currentStepIndex === this._stepElements.length - 1 : true;
377
+ const steps = this._steps;
378
+ const hasSteps = steps.length > 0;
379
+ const isLastStep = hasSteps ? this._currentStepIndex === steps.length - 1 : true;
267
380
 
268
381
  if (hasSteps && !isLastStep) {
269
382
  this._currentStepIndex++;
@@ -271,7 +384,6 @@ export class StForm extends LitElement {
271
384
  return;
272
385
  }
273
386
 
274
- // Use our custom extractor to pierce the Shadow DOM boundary
275
387
  const formData = this._extractFormData();
276
388
 
277
389
  this.dispatchEvent(
@@ -285,8 +397,9 @@ export class StForm extends LitElement {
285
397
  }
286
398
 
287
399
  render() {
288
- const hasSteps = this._stepElements?.length > 0;
289
- const isLastStep = hasSteps ? this._currentStepIndex === this._stepElements.length - 1 : true;
400
+ const steps = this._steps;
401
+ const hasSteps = steps.length > 0;
402
+ const isInModal = !!this.closest("st-modal");
290
403
 
291
404
  return html`
292
405
  <div class="${hasSteps ? "has-steps" : ""}" aria-busy=${this.loading}>
@@ -300,7 +413,7 @@ export class StForm extends LitElement {
300
413
  ${hasSteps
301
414
  ? html`
302
415
  <div class="progress-nav" role="tablist">
303
- ${this._stepElements.map((step, index) => {
416
+ ${steps.map((step, index) => {
304
417
  const isActive = index === this._currentStepIndex;
305
418
  const isCompleted = index < this._currentStepIndex;
306
419
 
@@ -319,7 +432,7 @@ export class StForm extends LitElement {
319
432
  </div>
320
433
  <span>${step.title || `Step ${index + 1}`}</span>
321
434
  </div>
322
- ${index < this._stepElements.length - 1
435
+ ${index < steps.length - 1
323
436
  ? html` <div class="step-connector ${isCompleted ? "completed" : ""}"></div> `
324
437
  : ""}
325
438
  `;
@@ -339,30 +452,33 @@ export class StForm extends LitElement {
339
452
  <form @submit=${this._handleSubmit}>
340
453
  <slot @slotchange=${this._handleSlotChange}></slot>
341
454
 
342
- <div class="form-actions">
343
- ${hasSteps && this._currentStepIndex > 0
344
- ? html`
455
+ ${!isInModal
456
+ ? html`
457
+ <div class="form-actions">
458
+ ${hasSteps && this._currentStepIndex > 0
459
+ ? html`
460
+ <st-button
461
+ variant="secondary"
462
+ type="button"
463
+ @click=${this._prevStep}
464
+ ?disabled=${this.loading}
465
+ >
466
+ Back
467
+ </st-button>
468
+ `
469
+ : ""}
470
+
345
471
  <st-button
346
- variant="secondary"
472
+ color="primary"
347
473
  type="button"
348
- @click=${this._prevStep}
474
+ @click=${this._triggerSubmission}
349
475
  ?disabled=${this.loading}
350
476
  >
351
- Back
477
+ ${this.loading ? "Submitting..." : hasSteps && this._currentStepIndex === steps.length - 1 ? "Submit" : hasSteps ? "Continue" : "Submit"}
352
478
  </st-button>
353
- `
354
- : ""}
355
-
356
- <!-- Type is "button", but click triggers requestSubmit() on the internal form -->
357
- <st-button
358
- color="primary"
359
- type="button"
360
- @click=${this._triggerSubmission}
361
- ?disabled=${this.loading}
362
- >
363
- ${this.loading ? "Submitting..." : isLastStep ? "Submit" : "Continue"}
364
- </st-button>
365
- </div>
479
+ </div>
480
+ `
481
+ : ""}
366
482
  </form>
367
483
  </div>
368
484
  `;
@@ -373,4 +489,4 @@ declare global {
373
489
  interface HTMLElementTagNameMap {
374
490
  "st-form": StForm;
375
491
  }
376
- }
492
+ }
@@ -119,6 +119,22 @@ export const iconPaths = {
119
119
  outline: svg`<rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="2" fill="none"></rect><line x1="3" y1="8" x2="21" y2="8" stroke="currentColor" stroke-width="2"></line><line x1="7" y1="8" x2="7" y2="21" stroke="currentColor" stroke-width="2"></line><line x1="17" y1="8" x2="17" y2="21" stroke="currentColor" stroke-width="2"></line><line x1="10" y1="14" x2="14" y2="14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></line>`,
120
120
  solid: svg`<path fill="currentColor" d="M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5zm2 3h14V5H5v3zM7 9H3v12h4V9zm14 0h-4v12h4V9z"></path>`,
121
121
  },
122
+ calender: {
123
+ outline: svg`<rect x="3" y="4" width="18" height="18" rx="2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></rect><line x1="16" y1="2" x2="16" y2="6" stroke="currentColor" stroke-width="2" stroke-linecap="round"></line><line x1="8" y1="2" x2="8" y2="6" stroke="currentColor" stroke-width="2" stroke-linecap="round"></line><line x1="3" y1="10" x2="21" y2="10" stroke="currentColor" stroke-width="2"></line>`,
124
+ solid: svg`<path fill="currentColor" d="M19 4h-1V2h-2v2H8V2H6v2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm0 16H5V9h14v11z"></path>`,
125
+ },
126
+ user: {
127
+ outline: svg`<circle cx="12" cy="8" r="4" fill="none" stroke="currentColor" stroke-width="2"></circle><path d="M4 20c0-4 4-6 8-6s8 2 8 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>`,
128
+ solid: svg`<path fill="currentColor" d="M12 12a5 5 0 1 0-5-5 5 5 0 0 0 5 5zm0 2c-3.33 0-10 1.67-10 5v3h20v-3c0-3.33-6.67-5-10-5z"></path>`,
129
+ },
130
+ home: {
131
+ outline: svg`<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path><polyline points="9 22 9 12 15 12 15 22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></polyline>`,
132
+ solid: svg`<path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"></path>`,
133
+ },
134
+ "circle-cross": {
135
+ outline: svg`<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></circle><line x1="15" y1="9" x2="9" y2="15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></line><line x1="9" y1="9" x2="15" y2="15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></line>`,
136
+ solid: svg`<path fill="currentColor" fill-rule="evenodd" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm3.36 6.64a1 1 0 0 1 0 1.41L13.41 12l1.95 1.95a1 1 0 0 1-1.41 1.41L12 13.41l-1.95 1.95a1 1 0 0 1-1.41-1.41L10.59 12 8.64 10.05a1 1 0 0 1 1.41-1.41L12 10.59l1.95-1.95a1 1 0 0 1 1.41 0z" clip-rule="evenodd"></path>`,
137
+ },
122
138
  };
123
139
 
124
140
  export type IconName = keyof typeof iconPaths;
@@ -3,10 +3,12 @@ import { customElement, property } from 'lit/decorators.js';
3
3
  import { iconPaths, IconName } from './icon-paths.js';
4
4
  import { resetStyles } from '../../styles/reset.js';
5
5
 
6
+ export type IconVariant = 'outline' | 'solid';
7
+
6
8
  @customElement('st-icon')
7
9
  export class StIcon extends LitElement {
8
10
  @property({ type: String }) name: IconName | string = '';
9
- @property({ type: String }) variant: 'outline' | 'solid' = 'outline';
11
+ @property({ type: String }) variant: IconVariant = 'outline';
10
12
 
11
13
  // Semantic Colors
12
14
  @property({ type: String, reflect: true }) color: 'default' | 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info' = 'default';
@@ -1,4 +1,4 @@
1
- import { LitElement, html, css, PropertyValues } from 'lit';
1
+ import { LitElement, html, css, PropertyValues, nothing } from 'lit';
2
2
  import { customElement, property, query } from 'lit/decorators.js';
3
3
  import { resetStyles } from '../../styles/reset.js';
4
4
 
@@ -14,8 +14,20 @@ export class StModal extends LitElement {
14
14
  */
15
15
  @property({ type: String }) heading = '';
16
16
 
17
+ /**
18
+ * Size of the modal (sm, md, lg, xl, xxl).
19
+ */
20
+ @property({ type: String }) size: 'sm' | 'md' | 'lg' | 'xl' | 'xxl' = 'md';
21
+
22
+ /**
23
+ * If true, the modal cannot be closed by clicking the backdrop or pressing Esc.
24
+ */
25
+ @property({ type: Boolean }) static = false;
26
+
17
27
  @query('dialog') private dialogEl!: HTMLDialogElement;
18
28
 
29
+ private isClosing = false;
30
+
19
31
  static styles = [
20
32
  resetStyles,
21
33
  css`
@@ -29,17 +41,59 @@ export class StModal extends LitElement {
29
41
  border-radius: var(--st-radius-lg, 0.5rem);
30
42
  background-color: var(--st-color-surface);
31
43
  color: var(--st-color-text);
32
- width: 100%;
33
- max-width: 500px;
44
+
45
+ /* Responsive width calculation */
46
+ width: calc(100% - 2rem);
47
+ margin: auto;
48
+
34
49
  box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
35
50
  font-family: var(--st-font-family, inherit);
36
51
  }
37
52
 
53
+ /* Sizes */
54
+ dialog.size-sm { max-width: 400px; }
55
+ dialog.size-md { max-width: 500px; }
56
+ dialog.size-lg { max-width: 800px; }
57
+ dialog.size-xl { max-width: 1024px; }
58
+ dialog.size-xxl { max-width: 1280px; }
59
+
38
60
  dialog::backdrop {
39
61
  background-color: rgba(0, 0, 0, 0.5);
40
62
  backdrop-filter: blur(2px);
41
63
  }
42
64
 
65
+ /* Animations */
66
+ @keyframes fade-in {
67
+ from { opacity: 0; }
68
+ to { opacity: 1; }
69
+ }
70
+ @keyframes fade-out {
71
+ from { opacity: 1; }
72
+ to { opacity: 0; }
73
+ }
74
+ @keyframes slide-up {
75
+ from { opacity: 0; transform: translateY(20px) scale(0.95); }
76
+ to { opacity: 1; transform: translateY(0) scale(1); }
77
+ }
78
+ @keyframes slide-down {
79
+ from { opacity: 1; transform: translateY(0) scale(1); }
80
+ to { opacity: 0; transform: translateY(20px) scale(0.95); }
81
+ }
82
+
83
+ dialog[open] {
84
+ animation: slide-up 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
85
+ }
86
+ dialog[open]::backdrop {
87
+ animation: fade-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
88
+ }
89
+
90
+ dialog.closing {
91
+ animation: slide-down 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
92
+ }
93
+ dialog.closing::backdrop {
94
+ animation: fade-out 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
95
+ }
96
+
43
97
  .modal-header {
44
98
  display: flex;
45
99
  align-items: center;
@@ -92,21 +146,47 @@ export class StModal extends LitElement {
92
146
 
93
147
  updated(changedProperties: PropertyValues) {
94
148
  if (changedProperties.has('open')) {
95
- if (this.dialogEl) {
96
- if (this.open && !this.dialogEl.open) {
149
+ if (this.open) {
150
+ this.dialogEl.classList.remove('closing');
151
+ this.isClosing = false;
152
+ if (!this.dialogEl.open) {
97
153
  this.dialogEl.showModal();
98
- } else if (!this.open && this.dialogEl.open) {
99
- this.dialogEl.close();
100
154
  }
155
+ } else if (this.dialogEl.open && !this.isClosing) {
156
+ this.closeWithAnimation();
101
157
  }
102
158
  }
103
159
  }
104
160
 
161
+ private closeWithAnimation() {
162
+ if (this.isClosing) return;
163
+ this.isClosing = true;
164
+ this.dialogEl.classList.add('closing');
165
+
166
+ const onAnimationEnd = () => {
167
+ this.dialogEl.removeEventListener('animationend', onAnimationEnd);
168
+ this.dialogEl.classList.remove('closing');
169
+ this.isClosing = false;
170
+ if (!this.open) {
171
+ this.dialogEl.close();
172
+ }
173
+ };
174
+
175
+ this.dialogEl.addEventListener('animationend', onAnimationEnd);
176
+ }
177
+
105
178
  private handleClose() {
106
179
  this.open = false;
107
180
  this.dispatchEvent(new CustomEvent('st-close', { bubbles: true, composed: true }));
108
181
  }
109
182
 
183
+ private handleCancel(e: Event) {
184
+ e.preventDefault();
185
+ if (!this.static) {
186
+ this.handleClose();
187
+ }
188
+ }
189
+
110
190
  private onDialogClose() {
111
191
  if (this.open) {
112
192
  this.open = false;
@@ -115,13 +195,16 @@ export class StModal extends LitElement {
115
195
  }
116
196
 
117
197
  private handleBackdropClick(e: MouseEvent) {
198
+ if (this.static) return;
199
+
118
200
  const rect = this.dialogEl.getBoundingClientRect();
119
- if (
201
+ const clickedOutside =
120
202
  e.clientX < rect.left ||
121
203
  e.clientX > rect.right ||
122
204
  e.clientY < rect.top ||
123
- e.clientY > rect.bottom
124
- ) {
205
+ e.clientY > rect.bottom;
206
+
207
+ if (clickedOutside) {
125
208
  this.handleClose();
126
209
  }
127
210
  }
@@ -129,17 +212,22 @@ export class StModal extends LitElement {
129
212
  render() {
130
213
  return html`
131
214
  <dialog
132
- @cancel=${this.handleClose}
215
+ class="size-${this.size}"
216
+ aria-labelledby=${this.heading ? 'modal-heading' : nothing}
217
+ @cancel=${this.handleCancel}
133
218
  @click=${this.handleBackdropClick}
134
219
  @close=${this.onDialogClose}
135
220
  >
136
221
  <div class="modal-header" @click=${(e: MouseEvent) => e.stopPropagation()}>
137
222
  <slot name="header">
138
- <h3 class="modal-title">${this.heading}</h3>
223
+ <h3 class="modal-title" id="modal-heading">${this.heading}</h3>
139
224
  </slot>
140
- <button class="close-btn" @click=${this.handleClose} aria-label="Close modal">
141
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
142
- </button>
225
+
226
+ ${!this.static ? html`
227
+ <button class="close-btn" @click=${this.handleClose} aria-label="Close modal">
228
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
229
+ </button>
230
+ ` : nothing}
143
231
  </div>
144
232
  <div class="modal-body" @click=${(e: MouseEvent) => e.stopPropagation()}>
145
233
  <slot></slot>