tyrell-components 1.0.0-TC56 → 1.0.0-TC57
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/css/tyrell.css +3 -3
- package/dist/tyrell.js +1 -1
- package/lib/base/ty-component.d.ts +3 -0
- package/lib/base/ty-component.js +5 -1
- package/lib/components/calendar.d.ts +2 -0
- package/lib/components/calendar.js +9 -0
- package/lib/components/checkbox.d.ts +2 -0
- package/lib/components/checkbox.js +7 -0
- package/lib/components/date-picker.d.ts +2 -0
- package/lib/components/date-picker.js +9 -2
- package/lib/components/file-upload.d.ts +2 -0
- package/lib/components/file-upload.js +15 -2
- package/lib/components/input.d.ts +74 -2
- package/lib/components/input.js +166 -6
- package/lib/components/modal.d.ts +2 -0
- package/lib/components/modal.js +12 -9
- package/lib/components/option.js +5 -0
- package/lib/components/popup.js +16 -4
- package/lib/components/radio.d.ts +2 -0
- package/lib/components/radio.js +8 -0
- package/lib/components/select.d.ts +2 -0
- package/lib/components/select.js +12 -0
- package/lib/components/selected-tags.d.ts +7 -1
- package/lib/components/selected-tags.js +10 -1
- package/lib/components/switch.d.ts +2 -0
- package/lib/components/switch.js +7 -0
- package/lib/components/textarea.d.ts +4 -0
- package/lib/components/textarea.js +7 -0
- package/lib/styles/date-picker.js +1 -1
- package/lib/styles/file-upload.d.ts +1 -1
- package/lib/styles/file-upload.js +3 -3
- package/lib/styles/icon.d.ts +1 -1
- package/lib/styles/icon.js +2 -2
- package/lib/styles/input.js +1 -1
- package/lib/styles/radio.js +2 -2
- package/lib/utils/property-manager.d.ts +6 -0
- package/lib/utils/property-manager.js +15 -5
- package/lib/utils/validity.d.ts +12 -0
- package/lib/utils/validity.js +65 -2
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +2 -2
package/lib/components/popup.js
CHANGED
|
@@ -5,6 +5,7 @@ import { popupStyles } from '../styles/popup.js';
|
|
|
5
5
|
const anchorClickHandlers = new WeakMap();
|
|
6
6
|
const anchorKeyClickHandlers = new WeakMap();
|
|
7
7
|
const outsideClickHandlers = new WeakMap();
|
|
8
|
+
const closeTimers = new WeakMap();
|
|
8
9
|
const escapeHandlers = new WeakMap();
|
|
9
10
|
const closeRequestHandlers = new WeakMap();
|
|
10
11
|
const popupIds = new WeakMap();
|
|
@@ -76,18 +77,29 @@ if (anchor?.hasAttribute('aria-haspopup'))
|
|
|
76
77
|
anchor.setAttribute('aria-expanded', 'false');
|
|
77
78
|
const exitMs = Math.max(0, ...getComputedStyle(dialog).transitionDuration
|
|
78
79
|
.split(',').map((d) => parseFloat(d) * (d.trim().endsWith('ms') ? 1 : 1000)));
|
|
79
|
-
|
|
80
|
+
clearTimeout(closeTimers.get(el));
|
|
81
|
+
closeTimers.set(el, setTimeout(() => {
|
|
82
|
+
closeTimers.delete(el);
|
|
80
83
|
dialog.classList.remove('position-calculated');
|
|
81
84
|
dialog.close();
|
|
82
|
-
}, exitMs + 20);
|
|
85
|
+
}, exitMs + 20));
|
|
83
86
|
}
|
|
84
87
|
function openPopup(el) {
|
|
85
|
-
const popupId = getPopupId(el);
|
|
86
|
-
lockScroll(popupId);
|
|
87
88
|
const shadowRoot = el.shadowRoot;
|
|
88
89
|
const dialog = shadowRoot ? getPopupDialog(shadowRoot) : null;
|
|
89
90
|
if (!dialog)
|
|
90
91
|
return;
|
|
92
|
+
if (dialog.open) {
|
|
93
|
+
const pending = closeTimers.get(el);
|
|
94
|
+
if (pending !== undefined) {
|
|
95
|
+
clearTimeout(pending);
|
|
96
|
+
closeTimers.delete(el);
|
|
97
|
+
dialog.classList.add('open');
|
|
98
|
+
}
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const popupId = getPopupId(el);
|
|
102
|
+
lockScroll(popupId);
|
|
91
103
|
dialog.showModal();
|
|
92
104
|
const anchor = getAnchorElement(el);
|
|
93
105
|
if (anchor?.hasAttribute('aria-haspopup'))
|
|
@@ -160,6 +160,8 @@ export declare class TyRadioGroup extends TyComponent<RadioGroupState> implement
|
|
|
160
160
|
* the accessors after upgrade. Each radio fires ty-radio-connected once
|
|
161
161
|
* upgraded, which re-syncs the group.
|
|
162
162
|
*/
|
|
163
|
+
/** Focus the group's tab stop: the checked radio, else the first enabled one. */
|
|
164
|
+
focus(options?: FocusOptions): void;
|
|
163
165
|
private getRadios;
|
|
164
166
|
private syncChildren;
|
|
165
167
|
/** Report `required` to the owning form — without this the asterisk
|
package/lib/components/radio.js
CHANGED
|
@@ -184,6 +184,14 @@ this.syncChildren();
|
|
|
184
184
|
getFormValue() {
|
|
185
185
|
return this.value || null;
|
|
186
186
|
}
|
|
187
|
+
focus(options) {
|
|
188
|
+
const enabled = this.getRadios().filter((r) => !r.disabled);
|
|
189
|
+
const stop = enabled.find((r) => r.checked) ?? enabled[0];
|
|
190
|
+
if (stop)
|
|
191
|
+
stop.focus(options);
|
|
192
|
+
else
|
|
193
|
+
super.focus(options);
|
|
194
|
+
}
|
|
187
195
|
getRadios() {
|
|
188
196
|
return Array.from(this.querySelectorAll("ty-radio"))
|
|
189
197
|
.filter((r) => typeof r.setTabStop === "function");
|
|
@@ -207,6 +207,8 @@ export declare class TySelect extends TyComponent<SelectState> {
|
|
|
207
207
|
private applyLoadingState;
|
|
208
208
|
/** Form value: a plain entry for single, FormData with repeated entries for multiple (HTMX standard). */
|
|
209
209
|
protected getFormValue(): FormDataEntryValue | FormData | null;
|
|
210
|
+
/** Focus the trigger: a slotted one, else the field. The host itself isn't focusable. */
|
|
211
|
+
focus(options?: FocusOptions): void;
|
|
210
212
|
private parseValue;
|
|
211
213
|
/** Initialize component state from the value property / pre-selected children. */
|
|
212
214
|
private initializeState;
|
package/lib/components/select.js
CHANGED
|
@@ -96,6 +96,7 @@ dialog.close();
|
|
|
96
96
|
});
|
|
97
97
|
this.render();
|
|
98
98
|
this.initializeState();
|
|
99
|
+
this.updateFormValue();
|
|
99
100
|
this._childObserver = new MutationObserver(() => {
|
|
100
101
|
this.ensureOptionRoles();
|
|
101
102
|
if (this._state.selectedValues.length > 0) {
|
|
@@ -250,8 +251,19 @@ formData.append(this._name, value);
|
|
|
250
251
|
});
|
|
251
252
|
return formData;
|
|
252
253
|
}
|
|
254
|
+
if (this._name && !this._multiple)
|
|
255
|
+
return "";
|
|
253
256
|
return null;
|
|
254
257
|
}
|
|
258
|
+
focus(options) {
|
|
259
|
+
const trigger = this.querySelector(':scope > [slot="trigger"]');
|
|
260
|
+
const stub = this.shadowRoot?.querySelector(".select-stub");
|
|
261
|
+
const target = trigger ?? stub;
|
|
262
|
+
if (target)
|
|
263
|
+
target.focus(options);
|
|
264
|
+
else
|
|
265
|
+
super.focus(options);
|
|
266
|
+
}
|
|
255
267
|
parseValue(value) {
|
|
256
268
|
if (!value || typeof value !== "string" || value.trim() === "")
|
|
257
269
|
return [];
|
|
@@ -28,6 +28,8 @@ export declare class TySelectedOptions extends HTMLElement {
|
|
|
28
28
|
* Interpolation context for a value: value, label, flavor plus every
|
|
29
29
|
* data-* attribute of the picker's matching option.
|
|
30
30
|
*/
|
|
31
|
+
/** The picker's option for a value (never its display clone). */
|
|
32
|
+
private option;
|
|
31
33
|
private context;
|
|
32
34
|
/**
|
|
33
35
|
* The template's blueprint fragment. HTML-parsed templates carry children in
|
|
@@ -42,7 +44,11 @@ export declare class TySelectedOptions extends HTMLElement {
|
|
|
42
44
|
* no flavor on the option).
|
|
43
45
|
*/
|
|
44
46
|
private stampTemplate;
|
|
45
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* Default chip when no template is provided: the option's own content,
|
|
49
|
+
* cloned, so a rich option makes a rich chip. An explicit `label` on the
|
|
50
|
+
* option replaces it with that text; a <template> child replaces it entirely.
|
|
51
|
+
*/
|
|
46
52
|
private defaultChip;
|
|
47
53
|
private renderChips;
|
|
48
54
|
/** Remove a value from the picker; picker re-emits change → chips re-render. */
|
|
@@ -93,8 +93,11 @@ return parseValues(this._picker?.value ?? '');
|
|
|
93
93
|
template() {
|
|
94
94
|
return this.querySelector(':scope > template');
|
|
95
95
|
}
|
|
96
|
+
option(value) {
|
|
97
|
+
return this._picker?.querySelector(`[value="${CSS.escape(value)}"]:not([cloned])`);
|
|
98
|
+
}
|
|
96
99
|
context(value) {
|
|
97
|
-
const opt = this.
|
|
100
|
+
const opt = this.option(value);
|
|
98
101
|
const ctx = {
|
|
99
102
|
value,
|
|
100
103
|
label: opt?.getAttribute('label') || opt?.textContent?.trim() || value,
|
|
@@ -150,12 +153,18 @@ return clone;
|
|
|
150
153
|
}
|
|
151
154
|
defaultChip(value) {
|
|
152
155
|
const ctx = this.context(value);
|
|
156
|
+
const opt = this.option(value);
|
|
153
157
|
const tag = document.createElement('ty-tag');
|
|
154
158
|
tag.setAttribute('value', value);
|
|
155
159
|
tag.setAttribute('dismissible', 'true');
|
|
156
160
|
if (ctx.flavor)
|
|
157
161
|
tag.setAttribute('flavor', ctx.flavor);
|
|
162
|
+
if (opt && !opt.hasAttribute('label') && opt.hasChildNodes()) {
|
|
163
|
+
opt.childNodes.forEach(n => tag.appendChild(n.cloneNode(true)));
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
158
166
|
tag.textContent = ctx.label;
|
|
167
|
+
}
|
|
159
168
|
tag.addEventListener('dismiss', () => this.deselect(value));
|
|
160
169
|
return tag;
|
|
161
170
|
}
|
|
@@ -82,6 +82,8 @@ export declare class TySwitch extends TyComponent<SwitchState> implements TySwit
|
|
|
82
82
|
private removeEventListeners;
|
|
83
83
|
private setupEventListeners;
|
|
84
84
|
protected render(): void;
|
|
85
|
+
/** Focus the switch itself (the host is not focusable; its role element is). */
|
|
86
|
+
focus(options?: FocusOptions): void;
|
|
85
87
|
/** required + unchecked => valueMissing; otherwise valid. */
|
|
86
88
|
private updateValidity;
|
|
87
89
|
get checked(): boolean;
|
package/lib/components/switch.js
CHANGED
|
@@ -145,6 +145,13 @@ this.setupEventListeners();
|
|
|
145
145
|
this.applyLabelName(switchEl);
|
|
146
146
|
this.updateValidity();
|
|
147
147
|
}
|
|
148
|
+
focus(options) {
|
|
149
|
+
const el = this.shadowRoot?.querySelector(".switch-container");
|
|
150
|
+
if (el)
|
|
151
|
+
el.focus(options);
|
|
152
|
+
else
|
|
153
|
+
super.focus(options);
|
|
154
|
+
}
|
|
148
155
|
updateValidity() {
|
|
149
156
|
const anchor = this.shadowRoot?.querySelector(".switch-container");
|
|
150
157
|
if (this.disabled) {
|
|
@@ -125,6 +125,8 @@ export declare class TyTextarea extends TyComponent<TextareaState> implements Ty
|
|
|
125
125
|
set label(v: string);
|
|
126
126
|
get disabled(): boolean;
|
|
127
127
|
set disabled(v: boolean);
|
|
128
|
+
/** Focus the inner <textarea>: the host itself isn't focusable. */
|
|
129
|
+
focus(options?: FocusOptions): void;
|
|
128
130
|
get required(): boolean;
|
|
129
131
|
set required(v: boolean);
|
|
130
132
|
get error(): string;
|
|
@@ -151,6 +153,8 @@ export declare class TyTextarea extends TyComponent<TextareaState> implements Ty
|
|
|
151
153
|
private _destroyScrollbar;
|
|
152
154
|
private emitValueEvent;
|
|
153
155
|
private handleInputEvent;
|
|
156
|
+
/** The user typed since the field was created: blur then shows its validation message. */
|
|
157
|
+
private _userEdited;
|
|
154
158
|
private _valueAtFocus;
|
|
155
159
|
private handleFocusEvent;
|
|
156
160
|
private handleBlurEvent;
|
|
@@ -13,6 +13,7 @@ this._dummyEl = null;
|
|
|
13
13
|
this._scrollbar = null;
|
|
14
14
|
this._isFocused = false;
|
|
15
15
|
this.handleInputEvent = (e) => {
|
|
16
|
+
this._userEdited = true;
|
|
16
17
|
const target = e.target;
|
|
17
18
|
const newValue = target.value;
|
|
18
19
|
this.value = newValue;
|
|
@@ -23,6 +24,7 @@ this._internals.setFormValue(this.value || '');
|
|
|
23
24
|
this.updateValidity();
|
|
24
25
|
this.emitValueEvent('input', e);
|
|
25
26
|
};
|
|
27
|
+
this._userEdited = false;
|
|
26
28
|
this._valueAtFocus = null;
|
|
27
29
|
this.handleFocusEvent = () => {
|
|
28
30
|
this._isFocused = true;
|
|
@@ -30,6 +32,8 @@ this._valueAtFocus = this.value;
|
|
|
30
32
|
};
|
|
31
33
|
this.handleBlurEvent = (e) => {
|
|
32
34
|
this._isFocused = false;
|
|
35
|
+
if (this._userEdited)
|
|
36
|
+
this.revealValidity();
|
|
33
37
|
if (this._valueAtFocus !== null && this.value !== this._valueAtFocus) {
|
|
34
38
|
this.emitValueEvent('change', e);
|
|
35
39
|
}
|
|
@@ -67,6 +71,9 @@ get label() { return this.getProperty('label'); }
|
|
|
67
71
|
set label(v) { this.setProperty('label', v); }
|
|
68
72
|
get disabled() { return this.getProperty('disabled'); }
|
|
69
73
|
set disabled(v) { this.setProperty('disabled', v); }
|
|
74
|
+
focus(options) {
|
|
75
|
+
this.shadowRoot?.querySelector('textarea')?.focus(options);
|
|
76
|
+
}
|
|
70
77
|
get required() { return this.getProperty('required'); }
|
|
71
78
|
set required(v) { this.setProperty('required', v); }
|
|
72
79
|
get error() { return this.getProperty('error'); }
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const fileUploadStyles = "\n:host {\n display: block;\n font-family: inherit;\n width: 100%;\n}\n\n.file-upload-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n box-sizing: border-box;\n}\n\n.upload-label {\n font-size: var(--ty-font-sm);\n line-height: var(--ty-leading-sm);\n letter-spacing: var(--ty-tracking-sm);\n font-weight: var(--ty-font-medium);\n color: var(--ty-label-color);\n margin-bottom: 6px;\n padding-left: 4px;\n display: flex;\n align-items: center;\n}\n\n.required-icon {\n color: var(--ty-color-danger);\n margin-left: 4px;\n font-size: 0.75rem;\n line-height: 1;\n}\n\n.drop-zone {\n border: 2px dashed var(--ty-border);\n border-radius: var(--ty-radius-base);\n padding: 2rem 1.5rem;\n text-align: center;\n cursor: pointer;\n transition: var(--ty-local-transition, border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease);\n outline: none;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n gap: 0.5rem;\n min-height: 140px;\n user-select: none;\n}\n\n.drop-zone:hover:not(.disabled) {\n border-color: var(--ty-input-border-hover);\n background: var(--ty-surface-content);\n}\n\n.drop-zone.focused {\n border-color: var(--ty-input-border-focus);\n box-shadow: 0 0 0 3px var(--ty-input-shadow-focus);\n}\n\n.drop-zone.drag-active {\n border-color: var(--ty-color-primary);\n border-style: solid;\n background: var(--ty-bg-primary-);\n}\n\n.drop-zone.drag-active .upload-icon {\n color: var(--ty-color-primary);\n transform: translateY(-2px);\n}\n\n.drop-zone.has-files {\n min-height: 80px;\n padding: 1.25rem 1.5rem;\n}\n\n.drop-zone.disabled {\n opacity: 0.5;\n cursor: not-allowed;\n pointer-events: none;\n}\n\n.drop-zone.error {\n border-color: var(--ty-color-danger);\n}\n\n.drop-zone.error:not(.disabled) {\n background: var(--ty-bg-danger-);\n}\n\n.upload-icon {\n color: var(--ty-text-faint);\n width: 2.5rem;\n height: 2.5rem;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: var(--ty-local-transition, color 0.15s ease, transform 0.15s ease);\n pointer-events: none;\n}\n\n.upload-icon svg {\n width: 100%;\n height: 100%;\n}\n\n.upload-hint {\n color: var(--ty-text-soft);\n font-size: var(--ty-font-sm);\n line-height: var(--ty-leading-sm);\n pointer-events: none;\n}\n\n.browse-link {\n color: var(--ty-color-primary);\n font-weight: var(--ty-font-medium);\n}\n\n.upload-sub-hint {\n color: var(--ty-text-faint);\n font-size: var(--ty-font-xs);\n line-height: var(--ty-leading-xs);\n pointer-events: none;\n}\n\n.file-list {\n display: flex;\n flex-direction: column;\n gap: 0.375rem;\n margin-top: 0.5rem;\n}\n\n.file-item {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n padding: 0.5rem 0.75rem;\n border-radius: var(--ty-radius-base);\n background: var(--ty-surface-content);\n border: 1px solid var(--ty-border-soft);\n}\n\n.file-icon {\n color: var(--ty-text-faint);\n flex-shrink: 0;\n width: 1rem;\n height: 1rem;\n display: flex;\n align-items: center;\n}\n\n.file-icon svg {\n width: 100%;\n height: 100%;\n}\n\n.file-name {\n flex: 1;\n font-size: var(--ty-font-sm);\n color: var(--ty-text);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n min-width: 0;\n}\n\n.file-size {\n font-size: var(--ty-font-xs);\n color: var(--ty-text-faint);\n flex-shrink: 0;\n font-variant-numeric: tabular-nums;\n}\n\n.file-remove {\n flex-shrink: 0;\n cursor: pointer;\n color: var(--ty-text-faint);\n width: 1.25rem;\n height: 1.25rem;\n display: flex;\n align-items: center;\n justify-content: center;\n border-radius: 50%;\n transition: var(--ty-local-transition, color 0.15s ease, background-color 0.15s ease);\n border: none;\n background: none;\n padding: 0;\n outline: none;\n}\n\n.file-remove:hover {\n color: var(--ty-color-danger);\n background: var(--ty-bg-danger-);\n}\n\n.file-remove:focus-visible {\n box-shadow: 0 0 0 2px var(--ty-color-danger);\n}\n\n.file-remove svg {\n width: 12px;\n height: 12px;\n}\n\n.error-message {\n font-size: var(--ty-font-xs);\n line-height: var(--ty-leading-xs);\n color: var(--ty-color-danger);\n margin-top: 4px;\n padding-left: 4px;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .drop-zone,\n .upload-icon,\n .file-remove {\n transition: none;\n }\n}\n\n@media (prefers-contrast: high) {\n .drop-zone {\n border-width: 3px;\n }\n}\n";
|
|
1
|
+
export declare const fileUploadStyles = "\n:host {\n display: block;\n font-family: inherit;\n width: 100%;\n}\n\n.file-upload-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n box-sizing: border-box;\n}\n\n.upload-label {\n font-size: var(--ty-font-sm);\n line-height: var(--ty-leading-sm);\n letter-spacing: var(--ty-tracking-sm);\n font-weight: var(--ty-font-medium);\n color: var(--ty-label-color);\n margin-bottom: 6px;\n padding-left: 4px;\n display: flex;\n align-items: center;\n}\n\n.required-icon {\n color: var(--ty-color-danger);\n margin-left: 4px;\n font-size: 0.75rem;\n line-height: 1;\n}\n\n.drop-zone {\n border: 2px dashed var(--ty-border);\n border-radius: var(--ty-radius-base);\n padding: 2rem 1.5rem;\n text-align: center;\n cursor: pointer;\n transition: var(--ty-local-transition, border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease);\n outline: none;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n gap: 0.5rem;\n min-height: 140px;\n user-select: none;\n}\n\n.drop-zone:hover:not(.disabled) {\n border-color: var(--ty-input-border-hover);\n background: var(--ty-surface-content);\n}\n\n.drop-zone.focused {\n border-color: var(--ty-input-border-focus);\n box-shadow: 0 0 0 3px var(--ty-input-shadow-focus);\n}\n\n.drop-zone.drag-active {\n border-color: var(--ty-color-primary);\n border-style: solid;\n background: var(--ty-bg-primary-soft);\n}\n\n.drop-zone.drag-active .upload-icon {\n color: var(--ty-color-primary);\n transform: translateY(-2px);\n}\n\n.drop-zone.has-files {\n min-height: 80px;\n padding: 1.25rem 1.5rem;\n}\n\n.drop-zone.disabled {\n opacity: 0.5;\n cursor: not-allowed;\n pointer-events: none;\n}\n\n.drop-zone.error {\n border-color: var(--ty-color-danger);\n}\n\n.drop-zone.error:not(.disabled) {\n background: var(--ty-bg-danger-soft);\n}\n\n.upload-icon {\n color: var(--ty-text-faint);\n width: 2.5rem;\n height: 2.5rem;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: var(--ty-local-transition, color 0.15s ease, transform 0.15s ease);\n pointer-events: none;\n}\n\n.upload-icon svg {\n width: 100%;\n height: 100%;\n}\n\n.upload-hint {\n color: var(--ty-text-soft);\n font-size: var(--ty-font-sm);\n line-height: var(--ty-leading-sm);\n pointer-events: none;\n}\n\n.browse-link {\n color: var(--ty-color-primary);\n font-weight: var(--ty-font-medium);\n}\n\n.upload-sub-hint {\n color: var(--ty-text-faint);\n font-size: var(--ty-font-xs);\n line-height: var(--ty-leading-xs);\n pointer-events: none;\n}\n\n.file-list {\n display: flex;\n flex-direction: column;\n gap: 0.375rem;\n margin-top: 0.5rem;\n}\n\n.file-item {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n padding: 0.5rem 0.75rem;\n border-radius: var(--ty-radius-base);\n background: var(--ty-surface-content);\n border: 1px solid var(--ty-border-soft);\n}\n\n.file-icon {\n color: var(--ty-text-faint);\n flex-shrink: 0;\n width: 1rem;\n height: 1rem;\n display: flex;\n align-items: center;\n}\n\n.file-icon svg {\n width: 100%;\n height: 100%;\n}\n\n.file-name {\n flex: 1;\n font-size: var(--ty-font-sm);\n color: var(--ty-text);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n min-width: 0;\n}\n\n.file-size {\n font-size: var(--ty-font-xs);\n color: var(--ty-text-faint);\n flex-shrink: 0;\n font-variant-numeric: tabular-nums;\n}\n\n.file-remove {\n flex-shrink: 0;\n cursor: pointer;\n color: var(--ty-text-faint);\n width: 1.25rem;\n height: 1.25rem;\n display: flex;\n align-items: center;\n justify-content: center;\n border-radius: 50%;\n transition: var(--ty-local-transition, color 0.15s ease, background-color 0.15s ease);\n border: none;\n background: none;\n padding: 0;\n outline: none;\n}\n\n.file-remove:hover {\n color: var(--ty-color-danger);\n background: var(--ty-bg-danger-soft);\n}\n\n.file-remove:focus-visible {\n box-shadow: 0 0 0 2px var(--ty-color-danger);\n}\n\n.file-remove svg {\n width: 12px;\n height: 12px;\n}\n\n.error-message {\n font-size: var(--ty-font-xs);\n line-height: var(--ty-leading-xs);\n color: var(--ty-color-danger);\n margin-top: 4px;\n padding-left: 4px;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .drop-zone,\n .upload-icon,\n .file-remove {\n transition: none;\n }\n}\n\n@media (prefers-contrast: high) {\n .drop-zone {\n border-width: 3px;\n }\n}\n";
|
|
@@ -54,7 +54,7 @@ box-shadow: 0 0 0 3px var(--ty-input-shadow-focus);
|
|
|
54
54
|
.drop-zone.drag-active {
|
|
55
55
|
border-color: var(--ty-color-primary);
|
|
56
56
|
border-style: solid;
|
|
57
|
-
background: var(--ty-bg-primary-);
|
|
57
|
+
background: var(--ty-bg-primary-soft);
|
|
58
58
|
}
|
|
59
59
|
.drop-zone.drag-active .upload-icon {
|
|
60
60
|
color: var(--ty-color-primary);
|
|
@@ -73,7 +73,7 @@ pointer-events: none;
|
|
|
73
73
|
border-color: var(--ty-color-danger);
|
|
74
74
|
}
|
|
75
75
|
.drop-zone.error:not(.disabled) {
|
|
76
|
-
background: var(--ty-bg-danger-);
|
|
76
|
+
background: var(--ty-bg-danger-soft);
|
|
77
77
|
}
|
|
78
78
|
.upload-icon {
|
|
79
79
|
color: var(--ty-text-faint);
|
|
@@ -165,7 +165,7 @@ outline: none;
|
|
|
165
165
|
}
|
|
166
166
|
.file-remove:hover {
|
|
167
167
|
color: var(--ty-color-danger);
|
|
168
|
-
background: var(--ty-bg-danger-);
|
|
168
|
+
background: var(--ty-bg-danger-soft);
|
|
169
169
|
}
|
|
170
170
|
.file-remove:focus-visible {
|
|
171
171
|
box-shadow: 0 0 0 2px var(--ty-color-danger);
|
package/lib/styles/icon.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Icon Component Styles */
|
|
2
|
-
export declare const iconStyles = "\n:host {\n /* Display & Layout - CRITICAL: No containment to prevent paint deferral */\n /* Block-level flex, NOT inline-flex: as an inline box the icon generates a\n line box in its parent, so a shrink-wrapping wrapper (badge, .rounded-full\n chip) inherits the parent's line-height strut and renders taller than wide\n \u2014 a 12px icon in a 24px-line-height badge becomes a 22x34 oval. To place an\n icon inline with text, make the *text wrapper* inline-flex instead. */\n display: flex;\n align-items: center;\n justify-content: center;\n line-height: 0;\n \n width: var(--ty-icon-size, 1em);\n height: var(--ty-icon-size, 1em);\n \n aspect-ratio: 1 / 1;\n \n \n color: inherit;\n transition: color
|
|
2
|
+
export declare const iconStyles = "\n:host {\n /* Display & Layout - CRITICAL: No containment to prevent paint deferral */\n /* Block-level flex, NOT inline-flex: as an inline box the icon generates a\n line box in its parent, so a shrink-wrapping wrapper (badge, .rounded-full\n chip) inherits the parent's line-height strut and renders taller than wide\n \u2014 a 12px icon in a 24px-line-height badge becomes a 22x34 oval. To place an\n icon inline with text, make the *text wrapper* inline-flex instead. */\n display: flex;\n align-items: center;\n justify-content: center;\n line-height: 0;\n \n width: var(--ty-icon-size, 1em);\n height: var(--ty-icon-size, 1em);\n \n aspect-ratio: 1 / 1;\n \n \n color: inherit;\n transition: color 150ms cubic-bezier(0.4, 0, 0.2, 1);\n \n flex-shrink: 0;\n \n /* Visibility - Force immediate rendering */\n visibility: visible !important;\n opacity: 1;\n \n /* ANTI-FLICKER: Force browser to paint immediately */\n will-change: contents;\n \n /* Prevent any sub-pixel rendering issues */\n transform: translateZ(0);\n -webkit-font-smoothing: antialiased;\n}\n\n/* The SVG element inside shadow DOM (registry / fallback path) */\n:host svg {\n width: 100%;\n height: 100%;\n display: block;\n\n /* Force immediate SVG rendering */\n will-change: auto;\n transform: translateZ(0);\n}\n\n/* Slotted SVG (server-side path: <ty-icon><svg>...</svg></ty-icon>).\n ::slotted() targets light-DOM children so they fill the host the same way\n shadow-DOM SVG does. Color is inherited from :host via currentColor. */\n::slotted(svg),\n::slotted(img) {\n width: 100%;\n height: 100%;\n display: block;\n}\n\n/* In a named slot the icon sits alongside text (button start/end), so inline\n layout is what the host expects there. */\n:host([slot]) {\n display: inline-flex;\n}\n\n:host(.icon-xs) {\n width: 0.75em;\n height: 0.75em;\n}\n\n:host(.icon-sm) {\n width: 0.875em;\n height: 0.875em;\n}\n\n:host(.icon-md) {\n width: 1em;\n height: 1em;\n}\n\n:host(.icon-lg) {\n width: 1.25em;\n height: 1.25em;\n}\n\n:host(.icon-xl) {\n width: 1.5em;\n height: 1.5em;\n}\n\n:host(.icon-2xl) {\n width: 2em;\n height: 2em;\n}\n\n/* Fixed pixel sizes */\n:host(.icon-12) {\n width: 12px;\n height: 12px;\n}\n\n:host(.icon-14) {\n width: 14px;\n height: 14px;\n}\n\n:host(.icon-16) {\n width: 16px;\n height: 16px;\n}\n\n:host(.icon-18) {\n width: 18px;\n height: 18px;\n}\n\n:host(.icon-20) {\n width: 20px;\n height: 20px;\n}\n\n:host(.icon-24) {\n width: 24px;\n height: 24px;\n}\n\n:host(.icon-32) {\n width: 32px;\n height: 32px;\n}\n\n:host(.icon-48) {\n width: 48px;\n height: 48px;\n}\n\n:host(.icon-64) {\n width: 64px;\n height: 64px;\n}\n\n:host(.icon-80) {\n width: 80px;\n height: 80px;\n}\n\n:host(.icon-96) {\n width: 96px;\n height: 96px;\n}\n\n:host(.icon-spin) {\n animation: icon-spin 1s linear infinite;\n}\n\n:host(.icon-spin.icon-tempo-slow) {\n animation: icon-spin 2s linear infinite;\n}\n\n:host(.icon-spin.icon-tempo-fast) {\n animation: icon-spin 0.5s linear infinite;\n}\n\n@keyframes icon-spin {\n from {\n transform: rotate(0deg);\n }\n\n to {\n transform: rotate(360deg);\n }\n}\n\n:host(.icon-pulse) {\n animation: icon-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;\n}\n\n:host(.icon-pulse.icon-tempo-slow) {\n animation: icon-pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;\n}\n\n:host(.icon-pulse.icon-tempo-fast) {\n animation: icon-pulse 1s cubic-bezier(0.4, 0, 0.6, 1) infinite;\n}\n\n@keyframes icon-pulse {\n 0%,\n 100% {\n opacity: 1;\n }\n\n 50% {\n opacity: 0.5;\n }\n}\n\n/* Accessibility: ensure icons are not announced by screen readers by default */\n:host {\n -webkit-user-select: none;\n user-select: none;\n}\n\n:host(:not(.icon-clickable)) {\n pointer-events: none;\n}\n\n:host(.icon-clickable) {\n cursor: pointer;\n pointer-events: auto;\n transition: color 150ms cubic-bezier(0.4, 0, 0.2, 1), transform 150ms cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n:host(.icon-clickable:hover) {\n transform: scale(1.1);\n}\n\n:host(.icon-clickable:active) {\n transform: scale(0.95);\n}\n\n:host(.icon-disabled) {\n opacity: 0.5;\n cursor: not-allowed;\n}\n\n:host(.icon-block) {\n display: block;\n}\n";
|
package/lib/styles/icon.js
CHANGED
|
@@ -8,7 +8,7 @@ width: var(--ty-icon-size, 1em);
|
|
|
8
8
|
height: var(--ty-icon-size, 1em);
|
|
9
9
|
aspect-ratio: 1 / 1;
|
|
10
10
|
color: inherit;
|
|
11
|
-
transition: color
|
|
11
|
+
transition: color 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
12
12
|
flex-shrink: 0;
|
|
13
13
|
visibility: visible !important;
|
|
14
14
|
opacity: 1;
|
|
@@ -145,7 +145,7 @@ pointer-events: none;
|
|
|
145
145
|
:host(.icon-clickable) {
|
|
146
146
|
cursor: pointer;
|
|
147
147
|
pointer-events: auto;
|
|
148
|
-
transition: color
|
|
148
|
+
transition: color 150ms cubic-bezier(0.4, 0, 0.2, 1), transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
149
149
|
}
|
|
150
150
|
:host(.icon-clickable:hover) {
|
|
151
151
|
transform: scale(1.1);
|
package/lib/styles/input.js
CHANGED
package/lib/styles/radio.js
CHANGED
|
@@ -27,13 +27,13 @@ display: flex;
|
|
|
27
27
|
flex-direction: column;
|
|
28
28
|
gap: 0.4rem;
|
|
29
29
|
font-family: inherit;
|
|
30
|
-
color: var(--ty-
|
|
30
|
+
color: var(--ty-text);
|
|
31
31
|
}
|
|
32
32
|
.radio-group-label {
|
|
33
33
|
font-size: var(--ty-font-sm);
|
|
34
34
|
line-height: var(--ty-leading-sm);
|
|
35
35
|
font-weight: var(--ty-font-medium);
|
|
36
|
-
color: var(--ty-
|
|
36
|
+
color: var(--ty-text);
|
|
37
37
|
display: flex;
|
|
38
38
|
align-items: center;
|
|
39
39
|
gap: 0.25rem;
|
|
@@ -29,6 +29,12 @@ export interface PropertyChange {
|
|
|
29
29
|
/**
|
|
30
30
|
* Manages component properties with unified lifecycle
|
|
31
31
|
*/
|
|
32
|
+
/**
|
|
33
|
+
* Boolean coercion for a property set from JS, HTML-style: '' (what React 19
|
|
34
|
+
* and Vue hand a property for a bare attribute) is true; false, null,
|
|
35
|
+
* undefined, 0, 'false' and '0' are false.
|
|
36
|
+
*/
|
|
37
|
+
export declare function coerceBoolean(value: unknown): boolean;
|
|
32
38
|
export declare class PropertyManager<T = any> {
|
|
33
39
|
private _props;
|
|
34
40
|
private _config;
|
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
export function coerceBoolean(value) {
|
|
2
|
+
if (typeof value === 'string') {
|
|
3
|
+
const normalized = value.toLowerCase().trim();
|
|
4
|
+
return normalized !== 'false' && normalized !== '0';
|
|
5
|
+
}
|
|
6
|
+
return Boolean(value);
|
|
7
|
+
}
|
|
8
|
+
function defaultOf(config) {
|
|
9
|
+
return typeof config.default === 'function' ? config.default() : config.default;
|
|
10
|
+
}
|
|
1
11
|
export class PropertyManager {
|
|
2
12
|
constructor(config) {
|
|
3
13
|
this._props = new Map();
|
|
@@ -7,7 +17,7 @@ this._initializeDefaults();
|
|
|
7
17
|
_initializeDefaults() {
|
|
8
18
|
for (const [name, config] of Object.entries(this._config)) {
|
|
9
19
|
if (config.default !== undefined) {
|
|
10
|
-
this._props.set(name, config
|
|
20
|
+
this._props.set(name, defaultOf(config));
|
|
11
21
|
}
|
|
12
22
|
}
|
|
13
23
|
}
|
|
@@ -43,7 +53,7 @@ if (config.coerce) {
|
|
|
43
53
|
return config.coerce(value);
|
|
44
54
|
}
|
|
45
55
|
if (value === null || value === undefined) {
|
|
46
|
-
return config
|
|
56
|
+
return defaultOf(config) ?? null;
|
|
47
57
|
}
|
|
48
58
|
switch (config.type) {
|
|
49
59
|
case 'boolean':
|
|
@@ -58,7 +68,7 @@ return true;
|
|
|
58
68
|
return Boolean(value);
|
|
59
69
|
case 'number':
|
|
60
70
|
const num = Number(value);
|
|
61
|
-
return isNaN(num) ? (config
|
|
71
|
+
return isNaN(num) ? (defaultOf(config) ?? 0) : num;
|
|
62
72
|
case 'string':
|
|
63
73
|
return String(value);
|
|
64
74
|
case 'object':
|
|
@@ -69,7 +79,7 @@ return JSON.parse(value);
|
|
|
69
79
|
}
|
|
70
80
|
catch {
|
|
71
81
|
console.warn(`[PropertyManager] Failed to parse ${name}:`, value);
|
|
72
|
-
return config
|
|
82
|
+
return defaultOf(config) ?? (config.type === 'array' ? [] : {});
|
|
73
83
|
}
|
|
74
84
|
}
|
|
75
85
|
return value;
|
|
@@ -119,7 +129,7 @@ handleAlias(aliasName, value) {
|
|
|
119
129
|
for (const [propName, config] of Object.entries(this._config)) {
|
|
120
130
|
if (config.aliases && aliasName in config.aliases) {
|
|
121
131
|
if (value === null)
|
|
122
|
-
return this.updateProperty(propName, config
|
|
132
|
+
return this.updateProperty(propName, defaultOf(config), 'attribute');
|
|
123
133
|
const aliasedValue = config.aliases[aliasName];
|
|
124
134
|
return this.updateProperty(propName, aliasedValue, 'attribute');
|
|
125
135
|
}
|
package/lib/utils/validity.d.ts
CHANGED
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
* reportValidity() uses the component's own `error` display when it has one
|
|
10
10
|
* (ty-input, ty-textarea, ty-radio-group, ty-file-upload) instead of the
|
|
11
11
|
* browser's bubble, and clears that message again once the field is valid.
|
|
12
|
+
* Whenever the browser fires `invalid` on such a field — a submit attempt,
|
|
13
|
+
* form.reportValidity(), or checkValidity() on the field or its form — the
|
|
14
|
+
* message goes into that error line (no bubble), so a plain <form> needs no
|
|
15
|
+
* JS. For a check that shows nothing, read `field.validity.valid` or
|
|
16
|
+
* `form.matches(':invalid')`. Only a real submit (a submitter click, or
|
|
17
|
+
* Enter) also moves focus to the first failing field.
|
|
12
18
|
*/
|
|
13
19
|
export declare class ValidityController {
|
|
14
20
|
private host;
|
|
@@ -18,6 +24,12 @@ export declare class ValidityController {
|
|
|
18
24
|
/** `error` was written by reportValidity(), so it's ours to clear. */
|
|
19
25
|
private errorFromReport;
|
|
20
26
|
constructor(host: HTMLElement, internals: ElementInternals);
|
|
27
|
+
/**
|
|
28
|
+
* Show the current message, if any, in the error display (on blur after an
|
|
29
|
+
* edit). While a pointer is pressed it waits until after pointerup.
|
|
30
|
+
*/
|
|
31
|
+
reveal(): void;
|
|
32
|
+
private showError;
|
|
21
33
|
/** The component's own validity (replaces the previous one). */
|
|
22
34
|
set(flags: ValidityStateFlags, message?: string, anchor?: HTMLElement): void;
|
|
23
35
|
setCustom(message: string): void;
|
package/lib/utils/validity.js
CHANGED
|
@@ -1,3 +1,43 @@
|
|
|
1
|
+
let submitAttempt = false;
|
|
2
|
+
let pointerPressed = false;
|
|
3
|
+
const pendingReveals = new Set();
|
|
4
|
+
let documentWatched = false;
|
|
5
|
+
function watchDocument() {
|
|
6
|
+
if (documentWatched)
|
|
7
|
+
return;
|
|
8
|
+
documentWatched = true;
|
|
9
|
+
const mark = () => {
|
|
10
|
+
submitAttempt = true;
|
|
11
|
+
setTimeout(() => { submitAttempt = false; });
|
|
12
|
+
};
|
|
13
|
+
document.addEventListener('click', (e) => {
|
|
14
|
+
const submitter = e.target?.closest?.('button:not([type=button]):not([type=reset]), input[type=submit], input[type=image], ' +
|
|
15
|
+
'ty-button:not([type=button]):not([type=reset])');
|
|
16
|
+
if (submitter)
|
|
17
|
+
mark();
|
|
18
|
+
}, true);
|
|
19
|
+
document.addEventListener('keydown', (e) => {
|
|
20
|
+
if (e.key === 'Enter')
|
|
21
|
+
mark();
|
|
22
|
+
}, true);
|
|
23
|
+
document.addEventListener('pointerdown', () => { pointerPressed = true; }, true);
|
|
24
|
+
const release = () => {
|
|
25
|
+
pointerPressed = false;
|
|
26
|
+
if (pendingReveals.size === 0)
|
|
27
|
+
return;
|
|
28
|
+
const waiting = [...pendingReveals];
|
|
29
|
+
pendingReveals.clear();
|
|
30
|
+
setTimeout(() => waiting.forEach((c) => c.reveal()));
|
|
31
|
+
};
|
|
32
|
+
document.addEventListener('pointerup', release, true);
|
|
33
|
+
document.addEventListener('pointercancel', release, true);
|
|
34
|
+
}
|
|
35
|
+
function firstInvalid(form) {
|
|
36
|
+
return Array.from(form.elements).find((el) => {
|
|
37
|
+
const v = el.validity;
|
|
38
|
+
return v && !v.valid && !el.disabled;
|
|
39
|
+
});
|
|
40
|
+
}
|
|
1
41
|
export class ValidityController {
|
|
2
42
|
constructor(host, internals) {
|
|
3
43
|
this.host = host;
|
|
@@ -5,6 +45,30 @@ this.internals = internals;
|
|
|
5
45
|
this.own = { flags: {} };
|
|
6
46
|
this.custom = '';
|
|
7
47
|
this.errorFromReport = false;
|
|
48
|
+
watchDocument();
|
|
49
|
+
host.addEventListener('invalid', (e) => {
|
|
50
|
+
if (!('error' in host))
|
|
51
|
+
return;
|
|
52
|
+
e.preventDefault();
|
|
53
|
+
this.showError();
|
|
54
|
+
if (!submitAttempt)
|
|
55
|
+
return;
|
|
56
|
+
const form = this.internals.form;
|
|
57
|
+
if (!form || firstInvalid(form) === host)
|
|
58
|
+
host.focus();
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
reveal() {
|
|
62
|
+
if (pointerPressed) {
|
|
63
|
+
pendingReveals.add(this);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if ('error' in this.host && !this.internals.validity.valid)
|
|
67
|
+
this.showError();
|
|
68
|
+
}
|
|
69
|
+
showError() {
|
|
70
|
+
this.host.setAttribute('error', this.internals.validationMessage);
|
|
71
|
+
this.errorFromReport = true;
|
|
8
72
|
}
|
|
9
73
|
set(flags, message, anchor) {
|
|
10
74
|
this.own = { flags, message, anchor };
|
|
@@ -19,8 +83,7 @@ const valid = this.internals.checkValidity();
|
|
|
19
83
|
if (valid)
|
|
20
84
|
return true;
|
|
21
85
|
if ('error' in this.host) {
|
|
22
|
-
this.
|
|
23
|
-
this.errorFromReport = true;
|
|
86
|
+
this.showError();
|
|
24
87
|
this.host.focus();
|
|
25
88
|
}
|
|
26
89
|
else {
|
package/lib/version.d.ts
CHANGED
package/lib/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = '1.0.0-
|
|
1
|
+
export const VERSION = '1.0.0-TC57';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tyrell-components",
|
|
3
|
-
"version": "1.0.0-
|
|
3
|
+
"version": "1.0.0-TC57",
|
|
4
4
|
"description": "Tyrell Components - Framework-agnostic web components with semantic design system",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -183,7 +183,7 @@
|
|
|
183
183
|
},
|
|
184
184
|
"repository": {
|
|
185
185
|
"type": "git",
|
|
186
|
-
"url": "https://github.com/gersak/tyrell.git",
|
|
186
|
+
"url": "git+https://github.com/gersak/tyrell.git",
|
|
187
187
|
"directory": "packages/core"
|
|
188
188
|
},
|
|
189
189
|
"homepage": "https://gersak.github.io/tyrell/",
|