tyrell-components 1.0.0-TC58 → 1.0.0-TC59
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-colors-static.css +14 -0
- package/css/tyrell-theme.css +1 -0
- package/css/tyrell.css +8 -6
- package/dist/tyrell-colors-static.css +1 -1
- package/dist/tyrell-theme.css +1 -1
- package/dist/tyrell.js +1 -1
- package/lib/base/ty-component.d.ts +8 -12
- package/lib/base/ty-component.js +38 -9
- package/lib/components/button.d.ts +10 -0
- package/lib/components/button.js +22 -1
- package/lib/components/calendar-month.d.ts +7 -1
- package/lib/components/calendar-month.js +32 -18
- package/lib/components/calendar-navigation.js +19 -6
- package/lib/components/calendar.d.ts +24 -5
- package/lib/components/calendar.js +83 -37
- package/lib/components/checkbox.d.ts +5 -0
- package/lib/components/checkbox.js +18 -5
- package/lib/components/copy.d.ts +2 -0
- package/lib/components/copy.js +15 -31
- package/lib/components/date-picker.d.ts +7 -26
- package/lib/components/date-picker.js +14 -84
- package/lib/components/file-upload.js +6 -2
- package/lib/components/input.d.ts +0 -9
- package/lib/components/input.js +17 -40
- package/lib/components/modal.js +59 -18
- package/lib/components/popup.js +56 -5
- package/lib/components/radio.d.ts +6 -0
- package/lib/components/radio.js +12 -28
- package/lib/components/select.d.ts +29 -11
- package/lib/components/select.js +43 -57
- package/lib/components/switch.d.ts +4 -0
- package/lib/components/switch.js +18 -5
- package/lib/components/tabs.js +1 -1
- package/lib/components/textarea.d.ts +1 -0
- package/lib/components/textarea.js +21 -8
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/styles/button.js +1 -0
- package/lib/styles/calendar-month.js +1 -1
- package/lib/styles/calendar.js +3 -0
- package/lib/styles/checkbox.js +11 -5
- package/lib/styles/date-picker.js +5 -10
- package/lib/styles/input.js +5 -9
- package/lib/styles/option.d.ts +1 -1
- package/lib/styles/option.js +4 -0
- package/lib/styles/popup.d.ts +1 -1
- package/lib/styles/popup.js +2 -0
- package/lib/styles/radio.js +10 -4
- package/lib/styles/select-base.js +5 -10
- package/lib/styles/select.d.ts +1 -1
- package/lib/styles/select.js +4 -0
- package/lib/styles/switch.js +10 -5
- package/lib/styles/tag.js +2 -1
- package/lib/utils/calendar-utils.d.ts +49 -0
- package/lib/utils/calendar-utils.js +63 -6
- package/lib/utils/scroll-lock.d.ts +10 -4
- package/lib/utils/scroll-lock.js +102 -2
- package/lib/utils/validity.d.ts +27 -1
- package/lib/utils/validity.js +68 -16
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +1 -1
- package/dist/tyrell.js.map +0 -1
- package/lib/utils/error-flavor.d.ts +0 -8
- package/lib/utils/error-flavor.js +0 -1
package/lib/styles/checkbox.js
CHANGED
|
@@ -14,14 +14,21 @@ export const checkboxStyles = `
|
|
|
14
14
|
:host {
|
|
15
15
|
display: inline-flex;
|
|
16
16
|
width: auto;
|
|
17
|
-
|
|
17
|
+
height: 1.25em;
|
|
18
|
+
height: 1lh;
|
|
19
|
+
align-items: center;
|
|
20
|
+
vertical-align: baseline;
|
|
21
|
+
overflow: visible;
|
|
22
|
+
}
|
|
23
|
+
:host::before {
|
|
24
|
+
content: "\\200b";
|
|
25
|
+
width: 0;
|
|
18
26
|
}
|
|
19
27
|
.checkbox-container {
|
|
20
28
|
display: inline-flex;
|
|
29
|
+
flex-shrink: 0;
|
|
21
30
|
align-items: center;
|
|
22
31
|
justify-content: center;
|
|
23
|
-
min-height: 1.25em;
|
|
24
|
-
min-height: 1lh;
|
|
25
32
|
outline: none;
|
|
26
33
|
transition: var(--ty-local-transition, all 0.15s ease-in-out);
|
|
27
34
|
user-select: none;
|
|
@@ -42,8 +49,7 @@ color: var(--checkbox-color-off-hover, color-mix(in oklab, var(--ty-text) 50%, t
|
|
|
42
49
|
.checkbox-container[aria-checked="true"] {
|
|
43
50
|
color: var(--checkbox-color, var(--ty-text));
|
|
44
51
|
}
|
|
45
|
-
.checkbox-container.focused
|
|
46
|
-
.checkbox-container:focus-visible {
|
|
52
|
+
.checkbox-container.focused {
|
|
47
53
|
box-shadow: 0 0 0 3px var(--checkbox-ring, color-mix(in oklab, var(--ty-color-primary) 25%, transparent));
|
|
48
54
|
}
|
|
49
55
|
.checkbox-icon {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { FLAVORS } from "../types/common.js";
|
|
2
2
|
import { fieldSizeVars } from "./field-size.js";
|
|
3
|
-
const datePickerFlavor = (f, fb) => {
|
|
3
|
+
const datePickerFlavor = (f, fb, also = "") => {
|
|
4
4
|
const c = (s) => fb
|
|
5
5
|
? s
|
|
6
6
|
? `var(--ty-color-${f}${s}, var(--ty-color-${f}, var(--ty-color-${fb}${s})))`
|
|
@@ -9,7 +9,7 @@ const c = (s) => fb
|
|
|
9
9
|
? `var(--ty-color-${f}${s}, var(--ty-color-${f}))`
|
|
10
10
|
: `var(--ty-color-${f})`;
|
|
11
11
|
return `
|
|
12
|
-
:host([flavor="${f}"]) {
|
|
12
|
+
:host([flavor="${f}"])${also} {
|
|
13
13
|
--date-picker-accent: var(--ty-input-${f}-border, ${c("-soft")});
|
|
14
14
|
--date-picker-accent-bold: ${c("")};
|
|
15
15
|
--date-picker-ring: color-mix(in oklab, ${c("")} 15%, transparent);
|
|
@@ -26,6 +26,7 @@ return `
|
|
|
26
26
|
}
|
|
27
27
|
`;
|
|
28
28
|
};
|
|
29
|
+
const errorLook = `,\n:host([error]:not([error=""]))`;
|
|
29
30
|
export const datePickerCustomFlavorCss = (base) => datePickerFlavor(base, "neutral");
|
|
30
31
|
export const datePickerStyles = `
|
|
31
32
|
${fieldSizeVars}
|
|
@@ -133,7 +134,7 @@ opacity: 0.6;
|
|
|
133
134
|
border-color: var(--date-picker-accent-bold, var(--ty-date-picker-border-focus));
|
|
134
135
|
box-shadow: 0 0 0 3px var(--date-picker-ring, var(--ty-date-picker-shadow-focus));
|
|
135
136
|
}
|
|
136
|
-
${FLAVORS.filter((f) => f !== "neutral").map((f) => datePickerFlavor(f)).join("")}
|
|
137
|
+
${FLAVORS.filter((f) => f !== "neutral").map((f) => datePickerFlavor(f, undefined, f === "danger" ? errorLook : "")).join("")}
|
|
137
138
|
.error-message {
|
|
138
139
|
font-size: var(--ty-font-xs);
|
|
139
140
|
line-height: var(--ty-leading-xs);
|
|
@@ -142,15 +143,9 @@ color: var(--ty-color-danger);
|
|
|
142
143
|
margin-top: 4px;
|
|
143
144
|
padding-left: var(--ty-field-outer-pad-x);
|
|
144
145
|
}
|
|
145
|
-
.date-picker-stub.error {
|
|
146
|
-
border-color: var(--ty-color-danger);
|
|
146
|
+
.date-picker-stub.error:not([disabled]) {
|
|
147
147
|
background: var(--ty-bg-danger-soft);
|
|
148
148
|
}
|
|
149
|
-
.date-picker-stub.error:focus,
|
|
150
|
-
.date-picker-stub.error.open {
|
|
151
|
-
border-color: var(--ty-color-danger-bold);
|
|
152
|
-
box-shadow: 0 0 0 3px var(--ty-date-picker-shadow-focus);
|
|
153
|
-
}
|
|
154
149
|
.stub-text {
|
|
155
150
|
flex: 1;
|
|
156
151
|
white-space: nowrap;
|
package/lib/styles/input.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { FLAVORS } from "../types/common.js";
|
|
2
2
|
import { fieldSizeVars } from "./field-size.js";
|
|
3
|
-
const inputFlavor = (f, fb) => {
|
|
3
|
+
const inputFlavor = (f, fb, also = "") => {
|
|
4
4
|
const c = (s) => fb
|
|
5
5
|
? s
|
|
6
6
|
? `var(--ty-color-${f}${s}, var(--ty-color-${f}, var(--ty-color-${fb}${s})))`
|
|
@@ -9,7 +9,7 @@ const c = (s) => fb
|
|
|
9
9
|
? `var(--ty-color-${f}${s}, var(--ty-color-${f}))`
|
|
10
10
|
: `var(--ty-color-${f})`;
|
|
11
11
|
return `
|
|
12
|
-
:host([flavor="${f}"]) {
|
|
12
|
+
:host([flavor="${f}"])${also} {
|
|
13
13
|
--input-accent: var(--ty-input-${f}-border, ${c("-soft")});
|
|
14
14
|
--input-accent-bold: ${c("")};
|
|
15
15
|
--input-ring: color-mix(in oklab, ${c("")} 15%, transparent);
|
|
@@ -26,6 +26,7 @@ return `
|
|
|
26
26
|
}
|
|
27
27
|
`;
|
|
28
28
|
};
|
|
29
|
+
const errorLook = `,\n:host([error]:not([error=""]))`;
|
|
29
30
|
export const inputCustomFlavorCss = (base) => inputFlavor(base, "neutral");
|
|
30
31
|
export const inputStyles = `
|
|
31
32
|
${fieldSizeVars}
|
|
@@ -137,14 +138,9 @@ color: var(--ty-color-danger);
|
|
|
137
138
|
margin-top: 4px;
|
|
138
139
|
padding-left: var(--ty-field-outer-pad-x);
|
|
139
140
|
}
|
|
140
|
-
.input-wrapper.error {
|
|
141
|
-
border-color: var(--ty-color-danger);
|
|
141
|
+
.input-wrapper.error:not(.disabled) {
|
|
142
142
|
background: var(--ty-bg-danger-soft);
|
|
143
143
|
}
|
|
144
|
-
.input-wrapper.error.focused {
|
|
145
|
-
border-color: var(--ty-color-danger-bold);
|
|
146
|
-
box-shadow: 0 0 0 3px var(--input-shadow-focus, var(--ty-input-shadow-focus));
|
|
147
|
-
}
|
|
148
144
|
input {
|
|
149
145
|
flex: 1;
|
|
150
146
|
min-width: 0;
|
|
@@ -177,7 +173,7 @@ input::placeholder {
|
|
|
177
173
|
color: var(--input-placeholder, var(--ty-input-placeholder));
|
|
178
174
|
font-weight: 400;
|
|
179
175
|
}
|
|
180
|
-
${FLAVORS.filter((f) => f !== "neutral").map((f) => inputFlavor(f)).join("")}
|
|
176
|
+
${FLAVORS.filter((f) => f !== "neutral").map((f) => inputFlavor(f, undefined, f === "danger" ? errorLook : "")).join("")}
|
|
181
177
|
input:focus-visible {
|
|
182
178
|
outline: none;
|
|
183
179
|
}
|
package/lib/styles/option.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Option Component Styles */
|
|
2
|
-
export declare const optionStyles = "\n.option-content {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: var(--ty-spacing-2);\n box-sizing: border-box;\n width: 100%;\n\n cursor: pointer;\n user-select: none;\n\n transition: var(--ty-transition-all);\n\n /* Linear-paired typography */\n color: var(--ty-text);\n font-family: inherit;\n font-size: var(--ty-font-sm);\n line-height: var(--ty-leading-sm);\n letter-spacing: var(--ty-tracking-sm);\n padding: var(--ty-spacing-2) var(--ty-spacing-3);\n border-radius: var(--ty-radius-md);\n}\n\n/* Cloned copy (projected into the select's trigger field): host defaults to\n inline like any unstyled custom element, which ignores width/overflow \u2014\n force a block box so the parent's flex-basis:0 truncation (select.ts\n .select-stub.has-clone slot[name=\"selected\"]) actually clips it, and\n collapse the content to a single ellipsized line instead of wrapping. */\n:host([cloned]) {\n display: block;\n min-width: 0;\n overflow: hidden;\n}\n\n/* The clone sits in the select's TRIGGER, so its type follows the field size\n ladder (styles/field-size.ts) rather than the dropdown list's own scale \u2014\n the --ty-field-* vars inherit from ty-select through the light DOM. The\n fallbacks cover a ty-option rendered outside a select. */\n:host([cloned]) .option-content {\n display: block;\n padding: 0px;\n background: transparent;\n min-width: 0;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n font-size: var(--ty-field-font, var(--ty-font-sm));\n line-height: var(--ty-field-leading, var(--ty-leading-sm));\n letter-spacing: var(--ty-field-tracking, var(--ty-tracking-sm));\n}\n\n.option-content:hover {\n background: var(--ty-bg-neutral-soft);\n}\n\n.option-content[highlighted] {\n background: var(--ty-bg-neutral-soft);\n color: var(--ty-text-strong);\n}\n\n/* Selected: quiet \u2014 bolder text color only, no background slab, no weight change */\n.option-content[selected] {\n background: transparent;\n color: var(--ty-text-bold);\n}\n\n/* Lucide check \u2014 right-aligned. Its box is always reserved so selecting\n (or highlighting) never changes the row's width and rewraps the text. */\n.option-check {\n display: block;\n visibility: hidden;\n flex-shrink: 0;\n width: 14px;\n height: 14px;\n margin-left: auto;\n color: var(--ty-color-primary);\n}\n\n.option-content[selected] .option-check {\n visibility: visible;\n}\n\n/* Never show the check on the copy cloned into the select's trigger field */\n:host([cloned]) .option-check {\n display: none;\n}\n\n.option-content[selected]:hover {\n background: var(--ty-bg-neutral-soft);\n}\n\n.option-content[disabled] {\n opacity: 0.5;\n cursor: not-allowed;\n}\n\n.option-content[hidden] {}\n\n.option-content * {\n pointer-events: none;\n}\n";
|
|
2
|
+
export declare const optionStyles = "\n/* Options the author hid and options a ty-select search left out, at any\n depth in the select (a framework component's element around each option\n too). !important beats the select's mobile list display:block. */\n:host([hidden]),\n:host([filtered-out]) {\n display: none !important;\n}\n\n.option-content {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: var(--ty-spacing-2);\n box-sizing: border-box;\n width: 100%;\n\n cursor: pointer;\n user-select: none;\n\n transition: var(--ty-transition-all);\n\n /* Linear-paired typography */\n color: var(--ty-text);\n font-family: inherit;\n font-size: var(--ty-font-sm);\n line-height: var(--ty-leading-sm);\n letter-spacing: var(--ty-tracking-sm);\n padding: var(--ty-spacing-2) var(--ty-spacing-3);\n border-radius: var(--ty-radius-md);\n}\n\n/* Cloned copy (projected into the select's trigger field): host defaults to\n inline like any unstyled custom element, which ignores width/overflow \u2014\n force a block box so the parent's flex-basis:0 truncation (select.ts\n .select-stub.has-clone slot[name=\"selected\"]) actually clips it, and\n collapse the content to a single ellipsized line instead of wrapping. */\n:host([cloned]) {\n display: block;\n min-width: 0;\n overflow: hidden;\n}\n\n/* The clone sits in the select's TRIGGER, so its type follows the field size\n ladder (styles/field-size.ts) rather than the dropdown list's own scale \u2014\n the --ty-field-* vars inherit from ty-select through the light DOM. The\n fallbacks cover a ty-option rendered outside a select. */\n:host([cloned]) .option-content {\n display: block;\n padding: 0px;\n background: transparent;\n min-width: 0;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n font-size: var(--ty-field-font, var(--ty-font-sm));\n line-height: var(--ty-field-leading, var(--ty-leading-sm));\n letter-spacing: var(--ty-field-tracking, var(--ty-tracking-sm));\n}\n\n.option-content:hover {\n background: var(--ty-bg-neutral-soft);\n}\n\n.option-content[highlighted] {\n background: var(--ty-bg-neutral-soft);\n color: var(--ty-text-strong);\n}\n\n/* Selected: quiet \u2014 bolder text color only, no background slab, no weight change */\n.option-content[selected] {\n background: transparent;\n color: var(--ty-text-bold);\n}\n\n/* Lucide check \u2014 right-aligned. Its box is always reserved so selecting\n (or highlighting) never changes the row's width and rewraps the text. */\n.option-check {\n display: block;\n visibility: hidden;\n flex-shrink: 0;\n width: 14px;\n height: 14px;\n margin-left: auto;\n color: var(--ty-color-primary);\n}\n\n.option-content[selected] .option-check {\n visibility: visible;\n}\n\n/* Never show the check on the copy cloned into the select's trigger field */\n:host([cloned]) .option-check {\n display: none;\n}\n\n.option-content[selected]:hover {\n background: var(--ty-bg-neutral-soft);\n}\n\n.option-content[disabled] {\n opacity: 0.5;\n cursor: not-allowed;\n}\n\n.option-content[hidden] {}\n\n.option-content * {\n pointer-events: none;\n}\n";
|
package/lib/styles/option.js
CHANGED
package/lib/styles/popup.d.ts
CHANGED
|
@@ -4,4 +4,4 @@
|
|
|
4
4
|
* Popup component with dropdown-like behavior and tooltip positioning.
|
|
5
5
|
* Uses dialog element for top layer rendering like dropdown and date-picker.
|
|
6
6
|
*/
|
|
7
|
-
export declare const popupStyles = "\n.popup-dialog {\n position: fixed;\n top: 0;\n left: 0;\n\n /* Reset dialog defaults */\n margin: 0;\n border: none;\n background: transparent;\n max-width: none;\n max-height: none;\n\n /* Shadow infrastructure - provide space for user shadows */\n padding: var(--popup-padding, 16px);\n\n /* Hidden by default - even when [open] (for positioning phase) */\n visibility: hidden;\n opacity: 0;\n /* NO TRANSFORM here - apply only when animating to avoid measurement errors! */\n\n transition:\n opacity var(--ty-popup-duration, 180ms) var(--ty-popup-ease, cubic-bezier(0.34, 1.56, 0.64, 1)),\n visibility var(--ty-popup-duration, 180ms) var(--ty-popup-ease, cubic-bezier(0.34, 1.56, 0.64, 1)),\n transform var(--ty-popup-duration, 180ms) var(--ty-popup-ease, cubic-bezier(0.34, 1.56, 0.64, 1));\n\n /* Prevent interactions when hidden */\n pointer-events: none;\n}\n\n/* Apply position variables only when position is calculated (like dropdown) */\n.popup-dialog.position-calculated {\n top: var(--popup-y, 0px);\n left: var(--popup-x, 0px);\n}\n\n/* Entrance prep (right before .open): scale + a short slide from the anchor\n side. data-side is the RESOLVED side (post-flip), set by updatePosition. */\n.popup-dialog.preparing-animation {\n transform: scale(var(--ty-popup-scale, 0.96));\n}\n\n/* The prep pose must land instantly. Transitioned, it had barely left\n scale(1) when .open arrived a frame later, so the entrance (and any\n springy --ty-popup-ease) ran over almost no distance. */\n.popup-dialog.preparing-animation:not(.open) {\n transition: none;\n}\n\n.popup-dialog[data-side=\"bottom\"].preparing-animation:not(.open) {\n transform: translateY(-6px) scale(var(--ty-popup-scale, 0.96));\n}\n\n.popup-dialog[data-side=\"top\"].preparing-animation:not(.open) {\n transform: translateY(6px) scale(var(--ty-popup-scale, 0.96));\n}\n\n.popup-dialog[data-side=\"left\"].preparing-animation:not(.open) {\n transform: translateX(6px) scale(var(--ty-popup-scale, 0.96));\n}\n\n.popup-dialog[data-side=\"right\"].preparing-animation:not(.open) {\n transform: translateX(-6px) scale(var(--ty-popup-scale, 0.96));\n}\n\n.popup-dialog.open {\n visibility: visible;\n opacity: 1;\n transform: scale(1);\n pointer-events: auto;\n}\n\n/* Make dialog backdrop transparent like dropdown/date-picker */\n.popup-dialog::backdrop {\n background: transparent;\n}\n\n/* Neutral structural container \u2014 users control all visuals via slotted content */\n.popup-container {\n display: contents;\n}\n\n/* Slotted content defines its own styling */\n#popup-content {\n display: contents;\n}\n\n/* Users have complete control over the popup's appearance; only essential\n positioning is imposed here. */\n#popup-content ::slotted(*) {\n position: relative;\n\n /* Prevent content from being too wide - still helpful */\n max-width: min(400px, 90vw);\n max-height: min(500px, 80vh);\n overflow: auto;\n}\n";
|
|
7
|
+
export declare const popupStyles = "\n.popup-dialog {\n position: fixed;\n top: 0;\n left: 0;\n\n /* Reset dialog defaults */\n margin: 0;\n border: none;\n background: transparent;\n max-width: none;\n max-height: none;\n\n /* Shadow infrastructure - provide space for user shadows */\n padding: var(--popup-padding, 16px);\n\n /* Hidden by default - even when [open] (for positioning phase) */\n visibility: hidden;\n opacity: 0;\n /* NO TRANSFORM here - apply only when animating to avoid measurement errors! */\n\n transition:\n opacity var(--ty-popup-duration, 180ms) var(--ty-popup-ease, cubic-bezier(0.34, 1.56, 0.64, 1)),\n visibility var(--ty-popup-duration, 180ms) var(--ty-popup-ease, cubic-bezier(0.34, 1.56, 0.64, 1)),\n transform var(--ty-popup-duration, 180ms) var(--ty-popup-ease, cubic-bezier(0.34, 1.56, 0.64, 1));\n\n /* Prevent interactions when hidden */\n pointer-events: none;\n\n /* Focused on open when the content has nothing to focus: not a control */\n outline: none;\n}\n\n/* Apply position variables only when position is calculated (like dropdown) */\n.popup-dialog.position-calculated {\n top: var(--popup-y, 0px);\n left: var(--popup-x, 0px);\n}\n\n/* Entrance prep (right before .open): scale + a short slide from the anchor\n side. data-side is the RESOLVED side (post-flip), set by updatePosition. */\n.popup-dialog.preparing-animation {\n transform: scale(var(--ty-popup-scale, 0.96));\n}\n\n/* The prep pose must land instantly. Transitioned, it had barely left\n scale(1) when .open arrived a frame later, so the entrance (and any\n springy --ty-popup-ease) ran over almost no distance. */\n.popup-dialog.preparing-animation:not(.open) {\n transition: none;\n}\n\n.popup-dialog[data-side=\"bottom\"].preparing-animation:not(.open) {\n transform: translateY(-6px) scale(var(--ty-popup-scale, 0.96));\n}\n\n.popup-dialog[data-side=\"top\"].preparing-animation:not(.open) {\n transform: translateY(6px) scale(var(--ty-popup-scale, 0.96));\n}\n\n.popup-dialog[data-side=\"left\"].preparing-animation:not(.open) {\n transform: translateX(6px) scale(var(--ty-popup-scale, 0.96));\n}\n\n.popup-dialog[data-side=\"right\"].preparing-animation:not(.open) {\n transform: translateX(-6px) scale(var(--ty-popup-scale, 0.96));\n}\n\n.popup-dialog.open {\n visibility: visible;\n opacity: 1;\n transform: scale(1);\n pointer-events: auto;\n /* Visible at once, the opacity fades it in: focusable as soon as openPopup\n moves focus in. Closing still keeps it visible through the fade-out. */\n transition-duration: var(--ty-popup-duration, 180ms), 0s, var(--ty-popup-duration, 180ms);\n}\n\n/* Make dialog backdrop transparent like dropdown/date-picker */\n.popup-dialog::backdrop {\n background: transparent;\n}\n\n/* Neutral structural container \u2014 users control all visuals via slotted content */\n.popup-container {\n display: contents;\n}\n\n/* Slotted content defines its own styling */\n#popup-content {\n display: contents;\n}\n\n/* Users have complete control over the popup's appearance; only essential\n positioning is imposed here. */\n#popup-content ::slotted(*) {\n position: relative;\n\n /* Prevent content from being too wide - still helpful */\n max-width: min(400px, 90vw);\n max-height: min(500px, 80vh);\n overflow: auto;\n}\n";
|
package/lib/styles/popup.js
CHANGED
|
@@ -16,6 +16,7 @@ opacity var(--ty-popup-duration, 180ms) var(--ty-popup-ease, cubic-bezier(0.34,
|
|
|
16
16
|
visibility var(--ty-popup-duration, 180ms) var(--ty-popup-ease, cubic-bezier(0.34, 1.56, 0.64, 1)),
|
|
17
17
|
transform var(--ty-popup-duration, 180ms) var(--ty-popup-ease, cubic-bezier(0.34, 1.56, 0.64, 1));
|
|
18
18
|
pointer-events: none;
|
|
19
|
+
outline: none;
|
|
19
20
|
}
|
|
20
21
|
.popup-dialog.position-calculated {
|
|
21
22
|
top: var(--popup-y, 0px);
|
|
@@ -44,6 +45,7 @@ visibility: visible;
|
|
|
44
45
|
opacity: 1;
|
|
45
46
|
transform: scale(1);
|
|
46
47
|
pointer-events: auto;
|
|
48
|
+
transition-duration: var(--ty-popup-duration, 180ms), 0s, var(--ty-popup-duration, 180ms);
|
|
47
49
|
}
|
|
48
50
|
.popup-dialog::backdrop {
|
|
49
51
|
background: transparent;
|
package/lib/styles/radio.js
CHANGED
|
@@ -16,7 +16,15 @@ ${inputStyles}
|
|
|
16
16
|
:host(ty-radio) {
|
|
17
17
|
display: inline-flex;
|
|
18
18
|
width: auto;
|
|
19
|
-
|
|
19
|
+
height: 1.25em;
|
|
20
|
+
height: 1lh;
|
|
21
|
+
align-items: center;
|
|
22
|
+
vertical-align: baseline;
|
|
23
|
+
overflow: visible;
|
|
24
|
+
}
|
|
25
|
+
:host(ty-radio)::before {
|
|
26
|
+
content: "\\200b";
|
|
27
|
+
width: 0;
|
|
20
28
|
}
|
|
21
29
|
:host(ty-radio-group) {
|
|
22
30
|
display: block;
|
|
@@ -68,13 +76,11 @@ height: 100%;
|
|
|
68
76
|
}
|
|
69
77
|
.radio-container {
|
|
70
78
|
display: inline-flex;
|
|
79
|
+
flex-shrink: 0;
|
|
71
80
|
align-items: center;
|
|
72
|
-
min-height: 1.25em;
|
|
73
|
-
min-height: 1lh;
|
|
74
81
|
cursor: pointer;
|
|
75
82
|
user-select: none;
|
|
76
83
|
outline: none;
|
|
77
|
-
vertical-align: middle;
|
|
78
84
|
}
|
|
79
85
|
.radio-container:not(.disabled):not([aria-checked="true"]):hover .radio-circle {
|
|
80
86
|
border-color: var(--radio-ring-off-hover, color-mix(in oklab, var(--ty-text) 80%, transparent));
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { FLAVORS } from "../types/common.js";
|
|
2
2
|
import { fieldSizeVars } from "./field-size.js";
|
|
3
3
|
import { customScrollbarStyles } from "./custom-scrollbar.js";
|
|
4
|
-
const selectFlavor = (f, fb) => {
|
|
4
|
+
const selectFlavor = (f, fb, also = "") => {
|
|
5
5
|
const c = (s) => fb
|
|
6
6
|
? s
|
|
7
7
|
? `var(--ty-color-${f}${s}, var(--ty-color-${f}, var(--ty-color-${fb}${s})))`
|
|
@@ -10,7 +10,7 @@ const c = (s) => fb
|
|
|
10
10
|
? `var(--ty-color-${f}${s}, var(--ty-color-${f}))`
|
|
11
11
|
: `var(--ty-color-${f})`;
|
|
12
12
|
return `
|
|
13
|
-
:host([flavor="${f}"]) {
|
|
13
|
+
:host([flavor="${f}"])${also} {
|
|
14
14
|
--select-accent: var(--ty-input-${f}-border, ${c("-soft")});
|
|
15
15
|
--select-accent-bold: ${c("")};
|
|
16
16
|
--select-ring: color-mix(in oklab, ${c("")} 15%, transparent);
|
|
@@ -27,6 +27,7 @@ return `
|
|
|
27
27
|
}
|
|
28
28
|
`;
|
|
29
29
|
};
|
|
30
|
+
const errorLook = `,\n:host([error]:not([error=""]))`;
|
|
30
31
|
export const selectCustomFlavorCss = (base) => selectFlavor(base, "neutral");
|
|
31
32
|
export const selectBaseStyles = `
|
|
32
33
|
${fieldSizeVars}
|
|
@@ -256,15 +257,9 @@ width: max(100%, 20em);
|
|
|
256
257
|
:host([has-trigger]) .error-message {
|
|
257
258
|
padding-left: 0;
|
|
258
259
|
}
|
|
259
|
-
.select-stub.error:not(.custom-trigger) {
|
|
260
|
-
border-color: var(--ty-color-danger);
|
|
260
|
+
.select-stub.error:not(.custom-trigger):not([disabled]) {
|
|
261
261
|
background: var(--ty-bg-danger-soft);
|
|
262
262
|
}
|
|
263
|
-
.select-stub.error:not(.custom-trigger):focus,
|
|
264
|
-
.select-stub.error:not(.custom-trigger):focus-visible {
|
|
265
|
-
border-color: var(--ty-color-danger-bold);
|
|
266
|
-
box-shadow: 0 0 0 3px var(--input-shadow-focus, var(--ty-input-shadow-focus));
|
|
267
|
-
}
|
|
268
263
|
.dropdown-mode-mobile .dropdown-wrapper:has(.mobile-dialog[open]) .select-chips {
|
|
269
264
|
display: none;
|
|
270
265
|
}
|
|
@@ -807,5 +802,5 @@ animation-duration: 1.6s;
|
|
|
807
802
|
}
|
|
808
803
|
}
|
|
809
804
|
${customScrollbarStyles}
|
|
810
|
-
${FLAVORS.filter((f) => f !== "neutral").map((f) => selectFlavor(f)).join("")}
|
|
805
|
+
${FLAVORS.filter((f) => f !== "neutral").map((f) => selectFlavor(f, undefined, f === "danger" ? errorLook : "")).join("")}
|
|
811
806
|
`;
|
package/lib/styles/select.d.ts
CHANGED
|
@@ -8,4 +8,4 @@
|
|
|
8
8
|
* - .compact: content-hugging trigger for toolbars, count badge
|
|
9
9
|
* - .custom-trigger (slot="trigger"): all chrome stripped
|
|
10
10
|
*/
|
|
11
|
-
export declare const selectStyles = "\n/* Compact skin hugs its content (field skin = base styles) */\n.select-stub.compact {\n display: inline-flex;\n flex-wrap: nowrap;\n width: auto;\n gap: 0.5rem;\n /* extra right padding = room for the absolutely-positioned chevron */\n padding: 0.5rem 2.25rem 0.5rem 0.875rem;\n}\n\n/* Consumer-provided trigger (slot=\"trigger\"): no chrome at all */\n.select-stub.custom-trigger {\n display: inline-block;\n width: auto;\n border: none;\n background: none;\n padding: 0;\n min-height: 0;\n border-radius: 0;\n}\n.select-stub.custom-trigger:hover {\n border: none;\n}\n\n.select-count {\n flex-shrink: 0;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n min-width: 1.25rem;\n height: 1.25rem;\n padding: 0 0.375rem;\n border-radius: 9999px;\n /* --ty-text-primary never existed as a token \u2014 this silently fell back\n to inherited/unset color. Now follows the select's own flavor. */\n background: color-mix(in oklab, var(--select-accent, var(--ty-color-primary)) 15%, transparent);\n color: var(--select-accent-bold, var(--ty-color-primary-strong));\n font-size: var(--ty-font-xs);\n font-weight: var(--ty-font-semibold);\n font-style: normal;\n}\n.select-count[hidden] {\n display: none;\n}\n\n/* Clear (x) button \u2014 normal flex child of .select-stub, not absolutely\n positioned like .dropdown-chevron: it just takes its own space in the\n row, no padding-right gutter math needed. Lives in the trigger slot's\n fallback content, so it's automatically absent under slot=\"trigger\". */\n.select-clear {\n flex-shrink: 0;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 1rem;\n height: 1rem;\n padding: 0;\n border: none;\n background: none;\n color: var(--input-placeholder, var(--ty-input-placeholder));\n cursor: pointer;\n border-radius: var(--ty-radius-sm);\n transition: var(--ty-local-transition, all 0.15s ease);\n}\n/* Pull toward the chevron: the stub's right padding (chevron room) left 12px\n between them on the field skin, 8px on compact. Both land at 6px. */\n.select-stub .select-clear {\n margin-right: -0.375rem;\n}\n.select-stub.compact .select-clear {\n margin-right: -0.125rem;\n}\n.select-clear[hidden] {\n display: none;\n}\n.select-clear:hover {\n /* Destructive utility action \u2014 neutral at rest, warns danger-red on\n hover regardless of the field's own flavor. */\n color: var(--ty-color-danger);\n background-color: var(--ty-bg-danger-soft);\n}\n.select-clear svg {\n width: 100%;\n height: 100%;\n}\n\n/* Selection text (field skin): single line, ellipsis, input colors.\n flex-basis 0, not auto \u2014 .select-stub is flex-wrap:wrap, and auto's\n hypothetical size is the FULL untruncated text width (wrap-line\n assignment happens before shrinking), which pushed fixed-size siblings\n (.select-clear) onto a second line even though the text visibly had\n room after truncating. 0 makes this item contribute ~nothing to the\n line-fit check up front; grow/shrink + overflow:hidden still truncate\n it identically. */\n.select-stub .dropdown-placeholder {\n font-style: normal;\n flex: 1 1 0%;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n color: var(--input-color, var(--ty-input-color));\n}\n\n.select-stub .dropdown-placeholder.placeholder-shown {\n color: var(--input-placeholder, var(--ty-input-placeholder));\n}\n.select-stub .dropdown-placeholder[hidden] {\n display: none;\n}\n\n/* Single-select display clone (slot=\"selected\"): the selected ty-option\n projected into the stub \u2014 its own :host([cloned]) styling strips the\n list-row chrome; here it just takes the text's flex slot. */\n.select-stub slot[name=\"selected\"] {\n display: none;\n}\n.select-stub.has-clone slot[name=\"selected\"] {\n display: block;\n /* flex-basis 0, not auto \u2014 same reasoning as .dropdown-placeholder above:\n .select-stub is flex-wrap:wrap, and auto's hypothetical size is the\n clone's full untruncated text width, which pushes .select-clear onto\n a second line. */\n flex: 1 1 0%;\n min-width: 0;\n overflow: hidden;\n}\n.select-stub.compact.has-clone slot[name=\"selected\"] {\n flex: 0 1 auto;\n}\n\n/* start/end slot content (icons etc.) \u2014 spacing comes from the stub's flex\n gap; muted like ty-input adornments. end sits before the chevron. */\n.select-stub ::slotted([slot=\"start\"]),\n.select-stub ::slotted([slot=\"end\"]) {\n flex-shrink: 0;\n color: var(--ty-text-soft);\n}\n\n/* Compact skin: the text IS the trigger label \u2014 no grow, regular text color.\n flex-basis: auto (not the field skin's 0%) \u2014 content-hugging needs its\n natural content width as the starting point; 0% would collapse it since\n grow is also 0 here (nothing left to expand it back out). */\n.select-stub.compact .dropdown-placeholder {\n flex: 0 1 auto;\n color: var(--ty-text);\n}\n\n/* Trigger stays visible while the popup is open (the popup hides it) */\n.dropdown-wrapper:has(.dropdown-chevron.open) .select-stub {\n opacity: 1;\n pointer-events: auto;\n}\n\n/* One-panel popup: search header fused with the options list */\n\n/* Single unified shadow around the whole panel (header + list as one silhouette) */\n.dropdown-dialog.open {\n filter: drop-shadow(0 12px 24px rgba(0, 0, 0, 0.14)) drop-shadow(0 2px 6px rgba(0, 0, 0, 0.08));\n}\n\n/* Card-style header: the search input keeps its own bordered-field look\n (base styles) and sits inset in the panel, TagPicker-style \u2014 no divider,\n whitespace separates it from the list. The header's OWN surface/border is\n part of the floating panel's chrome, not a form field \u2014 --ty-surface-\n floating/--ty-floating-border, matching .dropdown-options below it (same\n reasoning as the base-panel fix: --ty-input-* is deliberately faint for\n resting form fields and read as a missing/mismatched edge here). */\n.dropdown-header {\n background: var(--ty-surface-floating);\n border: 1px solid var(--ty-floating-border);\n border-bottom: none;\n border-radius: var(--ty-radius-lg) var(--ty-radius-lg) 0 0;\n padding: var(--ty-spacing-2) var(--ty-spacing-2) var(--ty-spacing-1);\n}\n.dropdown-dialog.position-below .dropdown-header {\n margin-bottom: 0;\n}\n.dropdown-dialog.position-above .dropdown-header {\n margin-top: 0;\n border-top: none;\n border-bottom: 1px solid var(--ty-floating-border);\n border-radius: 0 0 var(--ty-radius-lg) var(--ty-radius-lg);\n padding: var(--ty-spacing-1) var(--ty-spacing-2) var(--ty-spacing-2);\n}\n\n/* Magnifier adornment inside the search field (header padding + field inset) */\n.dropdown-search-icon {\n position: absolute;\n top: 50%;\n left: calc(var(--ty-spacing-2) + var(--ty-spacing-3));\n transform: translateY(-50%);\n width: 1rem;\n height: 1rem;\n color: var(--input-placeholder, var(--ty-input-placeholder));\n pointer-events: none;\n}\n.dropdown-search-icon svg {\n width: 100%;\n height: 100%;\n}\n\n/* Mobile header: flex row, icon flows inline before the input. Same 32x32\n footprint as .mobile-close-button (18px glyph centered in the box, not a\n bare 18px glyph flush at the edge) so the row has two matching bookends \u2014\n a bare icon on one side and a 32px circle on the other read as lopsided\n even with equal flex gap on both sides. Matching them also centers the\n search input's flex:1 fill symmetrically between the two. Fixed white +\n 18px glyph, same reasoning as .mobile-header-label \u2014 this sits on the\n dark ::backdrop blur in both themes, not a theme surface. */\n.dropdown-mode-mobile .mobile-header-content .dropdown-search-icon {\n position: static;\n transform: none;\n flex-shrink: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n width: 32px;\n height: 32px;\n color: #ffffff;\n}\n.dropdown-mode-mobile .mobile-header-content .dropdown-search-icon svg {\n width: 18px;\n height: 18px;\n}\n\n/* Search input: bordered field from base styles; just size + icon room */\n.dropdown-search-input {\n min-height: 2.25rem;\n padding-right: var(--ty-spacing-2);\n /* room for the magnifier adornment */\n padding-left: calc(var(--ty-spacing-3) + 1rem + var(--ty-spacing-2));\n}\n/* Trigger already has a chevron - no second one inside the search row */\n.dropdown-search-chevron {\n display: none;\n}\n\n/* With the search row hidden nothing inside is focusable, so the <dialog>\n itself receives focus on open \u2014 suppress its UA focus ring */\n.dropdown-dialog:focus,\n.dropdown-dialog:focus-visible {\n outline: none;\n}\n\n/* Search row hidden ('auto' below threshold / searchable=\"false\"):\n the options panel owns the whole silhouette */\n.dropdown-header[hidden] {\n display: none;\n}\n.dropdown-header[hidden] ~ .dropdown-options-wrapper .dropdown-options {\n border-top: 1px solid var(--ty-floating-border);\n border-radius: var(--ty-radius-lg);\n}\n.mobile-search-input[hidden],\n.mobile-header-content .dropdown-search-icon[hidden] {\n display: none;\n}\n\n/* Options: vertical list rows fused under the search header.\n No own shadow/animation - the panel appears as one via dialog opacity. */\n.dropdown-options {\n flex-direction: column;\n flex-wrap: nowrap;\n align-items: stretch;\n gap: 0;\n /* Card-style list: rounded rows sit inset from the panel edges */\n padding: var(--ty-spacing-1);\n border-top: none;\n border-radius: 0 0 var(--ty-radius-lg) var(--ty-radius-lg);\n box-shadow: none;\n transform: none;\n transition: none;\n /* ~9-10 visible rows before scrolling (custom scrollbar, native hidden) */\n max-height: 24rem;\n}\n.dropdown-dialog.position-below .dropdown-options {\n box-shadow: none;\n}\n.dropdown-dialog.position-above .dropdown-options {\n border-top: 1px solid var(--ty-floating-border);\n border-bottom: none;\n border-radius: var(--ty-radius-lg) var(--ty-radius-lg) 0 0;\n}\n\n/* Loading panel continues the fused silhouette exactly like .dropdown-options \u2014\n the base layer styles it as a floating card (radius + shadow), which left a\n see-through gap under the search header while loading. */\n.dropdown-loading {\n border-top: none;\n border-radius: 0 0 var(--ty-radius-lg) var(--ty-radius-lg);\n box-shadow: none;\n}\n.dropdown-dialog.position-above .dropdown-loading {\n border-top: 1px solid var(--ty-floating-border);\n border-bottom: none;\n border-radius: var(--ty-radius-lg) var(--ty-radius-lg) 0 0;\n}\n\n/* Selection tick lives inside ty-option (lucide check on [selected]) */\n\n/* Mobile modal: same vertical list */\n.dropdown-mode-mobile .section-content ::slotted(ty-option) {\n display: block;\n width: 100%;\n}\n";
|
|
11
|
+
export declare const selectStyles = "\n/* Compact skin hugs its content (field skin = base styles) */\n.select-stub.compact {\n display: inline-flex;\n flex-wrap: nowrap;\n width: auto;\n gap: 0.5rem;\n /* extra right padding = room for the absolutely-positioned chevron */\n padding: 0.5rem 2.25rem 0.5rem 0.875rem;\n}\n\n/* Consumer-provided trigger (slot=\"trigger\"): no chrome at all */\n.select-stub.custom-trigger {\n display: inline-block;\n width: auto;\n border: none;\n background: none;\n padding: 0;\n min-height: 0;\n border-radius: 0;\n}\n.select-stub.custom-trigger:hover {\n border: none;\n}\n\n.select-count {\n flex-shrink: 0;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n min-width: 1.25rem;\n height: 1.25rem;\n padding: 0 0.375rem;\n border-radius: 9999px;\n /* --ty-text-primary never existed as a token \u2014 this silently fell back\n to inherited/unset color. Now follows the select's own flavor. */\n background: color-mix(in oklab, var(--select-accent, var(--ty-color-primary)) 15%, transparent);\n color: var(--select-accent-bold, var(--ty-color-primary-strong));\n font-size: var(--ty-font-xs);\n font-weight: var(--ty-font-semibold);\n font-style: normal;\n}\n.select-count[hidden] {\n display: none;\n}\n\n/* Clear (x) button \u2014 normal flex child of .select-stub, not absolutely\n positioned like .dropdown-chevron: it just takes its own space in the\n row, no padding-right gutter math needed. Lives in the trigger slot's\n fallback content, so it's automatically absent under slot=\"trigger\". */\n.select-clear {\n flex-shrink: 0;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 1rem;\n height: 1rem;\n padding: 0;\n border: none;\n background: none;\n color: var(--input-placeholder, var(--ty-input-placeholder));\n cursor: pointer;\n border-radius: var(--ty-radius-sm);\n transition: var(--ty-local-transition, all 0.15s ease);\n}\n/* Pull toward the chevron: the stub's right padding (chevron room) left 12px\n between them on the field skin, 8px on compact. Both land at 6px. */\n.select-stub .select-clear {\n margin-right: -0.375rem;\n}\n.select-stub.compact .select-clear {\n margin-right: -0.125rem;\n}\n.select-clear[hidden] {\n display: none;\n}\n.select-clear:hover {\n /* Destructive utility action \u2014 neutral at rest, warns danger-red on\n hover regardless of the field's own flavor. */\n color: var(--ty-color-danger);\n background-color: var(--ty-bg-danger-soft);\n}\n.select-clear svg {\n width: 100%;\n height: 100%;\n}\n\n/* Selection text (field skin): single line, ellipsis, input colors.\n flex-basis 0, not auto \u2014 .select-stub is flex-wrap:wrap, and auto's\n hypothetical size is the FULL untruncated text width (wrap-line\n assignment happens before shrinking), which pushed fixed-size siblings\n (.select-clear) onto a second line even though the text visibly had\n room after truncating. 0 makes this item contribute ~nothing to the\n line-fit check up front; grow/shrink + overflow:hidden still truncate\n it identically. */\n.select-stub .dropdown-placeholder {\n font-style: normal;\n flex: 1 1 0%;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n color: var(--input-color, var(--ty-input-color));\n}\n\n.select-stub .dropdown-placeholder.placeholder-shown {\n color: var(--input-placeholder, var(--ty-input-placeholder));\n}\n.select-stub .dropdown-placeholder[hidden] {\n display: none;\n}\n\n/* Single-select display clone (slot=\"selected\"): the selected ty-option\n projected into the stub \u2014 its own :host([cloned]) styling strips the\n list-row chrome; here it just takes the text's flex slot. */\n.select-stub slot[name=\"selected\"] {\n display: none;\n}\n.select-stub.has-clone slot[name=\"selected\"] {\n display: block;\n /* flex-basis 0, not auto \u2014 same reasoning as .dropdown-placeholder above:\n .select-stub is flex-wrap:wrap, and auto's hypothetical size is the\n clone's full untruncated text width, which pushes .select-clear onto\n a second line. */\n flex: 1 1 0%;\n min-width: 0;\n overflow: hidden;\n}\n.select-stub.compact.has-clone slot[name=\"selected\"] {\n flex: 0 1 auto;\n}\n\n/* start/end slot content (icons etc.) \u2014 spacing comes from the stub's flex\n gap; muted like ty-input adornments. end sits before the chevron. */\n.select-stub ::slotted([slot=\"start\"]),\n.select-stub ::slotted([slot=\"end\"]) {\n flex-shrink: 0;\n color: var(--ty-text-soft);\n}\n\n/* Compact skin: the text IS the trigger label \u2014 no grow, regular text color.\n flex-basis: auto (not the field skin's 0%) \u2014 content-hugging needs its\n natural content width as the starting point; 0% would collapse it since\n grow is also 0 here (nothing left to expand it back out). */\n.select-stub.compact .dropdown-placeholder {\n flex: 0 1 auto;\n color: var(--ty-text);\n}\n\n/* Trigger stays visible while the popup is open (the popup hides it) */\n.dropdown-wrapper:has(.dropdown-chevron.open) .select-stub {\n opacity: 1;\n pointer-events: auto;\n}\n\n/* One-panel popup: search header fused with the options list */\n\n/* Single unified shadow around the whole panel (header + list as one silhouette) */\n.dropdown-dialog.open {\n filter: drop-shadow(0 12px 24px rgba(0, 0, 0, 0.14)) drop-shadow(0 2px 6px rgba(0, 0, 0, 0.08));\n}\n\n/* Card-style header: the search input keeps its own bordered-field look\n (base styles) and sits inset in the panel, TagPicker-style \u2014 no divider,\n whitespace separates it from the list. The header's OWN surface/border is\n part of the floating panel's chrome, not a form field \u2014 --ty-surface-\n floating/--ty-floating-border, matching .dropdown-options below it (same\n reasoning as the base-panel fix: --ty-input-* is deliberately faint for\n resting form fields and read as a missing/mismatched edge here). */\n.dropdown-header {\n background: var(--ty-surface-floating);\n border: 1px solid var(--ty-floating-border);\n border-bottom: none;\n border-radius: var(--ty-radius-lg) var(--ty-radius-lg) 0 0;\n padding: var(--ty-spacing-2) var(--ty-spacing-2) var(--ty-spacing-1);\n}\n.dropdown-dialog.position-below .dropdown-header {\n margin-bottom: 0;\n}\n.dropdown-dialog.position-above .dropdown-header {\n margin-top: 0;\n border-top: none;\n border-bottom: 1px solid var(--ty-floating-border);\n border-radius: 0 0 var(--ty-radius-lg) var(--ty-radius-lg);\n padding: var(--ty-spacing-1) var(--ty-spacing-2) var(--ty-spacing-2);\n}\n\n/* Magnifier adornment inside the search field (header padding + field inset) */\n.dropdown-search-icon {\n position: absolute;\n top: 50%;\n left: calc(var(--ty-spacing-2) + var(--ty-spacing-3));\n transform: translateY(-50%);\n width: 1rem;\n height: 1rem;\n color: var(--input-placeholder, var(--ty-input-placeholder));\n pointer-events: none;\n}\n.dropdown-search-icon svg {\n width: 100%;\n height: 100%;\n}\n\n/* Mobile header: flex row, icon flows inline before the input. Same 32x32\n footprint as .mobile-close-button (18px glyph centered in the box, not a\n bare 18px glyph flush at the edge) so the row has two matching bookends \u2014\n a bare icon on one side and a 32px circle on the other read as lopsided\n even with equal flex gap on both sides. Matching them also centers the\n search input's flex:1 fill symmetrically between the two. Fixed white +\n 18px glyph, same reasoning as .mobile-header-label \u2014 this sits on the\n dark ::backdrop blur in both themes, not a theme surface. */\n.dropdown-mode-mobile .mobile-header-content .dropdown-search-icon {\n position: static;\n transform: none;\n flex-shrink: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n width: 32px;\n height: 32px;\n color: #ffffff;\n}\n.dropdown-mode-mobile .mobile-header-content .dropdown-search-icon svg {\n width: 18px;\n height: 18px;\n}\n\n/* Search input: bordered field from base styles; just size + icon room */\n.dropdown-search-input {\n min-height: 2.25rem;\n padding-right: var(--ty-spacing-2);\n /* room for the magnifier adornment */\n padding-left: calc(var(--ty-spacing-3) + 1rem + var(--ty-spacing-2));\n}\n/* Trigger already has a chevron - no second one inside the search row */\n.dropdown-search-chevron {\n display: none;\n}\n\n/* With the search row hidden nothing inside is focusable, so the <dialog>\n itself receives focus on open \u2014 suppress its UA focus ring */\n.dropdown-dialog:focus,\n.dropdown-dialog:focus-visible {\n outline: none;\n}\n\n/* Search row hidden ('auto' below threshold / searchable=\"false\"):\n the options panel owns the whole silhouette */\n.dropdown-header[hidden] {\n display: none;\n}\n.dropdown-header[hidden] ~ .dropdown-options-wrapper .dropdown-options {\n border-top: 1px solid var(--ty-floating-border);\n border-radius: var(--ty-radius-lg);\n}\n.mobile-search-input[hidden],\n.mobile-header-content .dropdown-search-icon[hidden] {\n display: none;\n}\n\n/* Options: vertical list rows fused under the search header.\n No own shadow/animation - the panel appears as one via dialog opacity. */\n.dropdown-options {\n flex-direction: column;\n flex-wrap: nowrap;\n align-items: stretch;\n gap: 0;\n /* Card-style list: rounded rows sit inset from the panel edges */\n padding: var(--ty-spacing-1);\n border-top: none;\n border-radius: 0 0 var(--ty-radius-lg) var(--ty-radius-lg);\n box-shadow: none;\n transform: none;\n transition: none;\n /* ~9-10 visible rows before scrolling (custom scrollbar, native hidden) */\n max-height: 24rem;\n}\n.dropdown-dialog.position-below .dropdown-options {\n box-shadow: none;\n}\n.dropdown-dialog.position-above .dropdown-options {\n border-top: 1px solid var(--ty-floating-border);\n border-bottom: none;\n border-radius: var(--ty-radius-lg) var(--ty-radius-lg) 0 0;\n}\n\n/* Loading panel continues the fused silhouette exactly like .dropdown-options \u2014\n the base layer styles it as a floating card (radius + shadow), which left a\n see-through gap under the search header while loading. */\n.dropdown-loading {\n border-top: none;\n border-radius: 0 0 var(--ty-radius-lg) var(--ty-radius-lg);\n box-shadow: none;\n}\n.dropdown-dialog.position-above .dropdown-loading {\n border-top: 1px solid var(--ty-floating-border);\n border-bottom: none;\n border-radius: var(--ty-radius-lg) var(--ty-radius-lg) 0 0;\n}\n\n/* Selection tick lives inside ty-option (lucide check on [selected]) */\n\n/* Mobile modal: same vertical list */\n.dropdown-mode-mobile .section-content ::slotted(ty-option) {\n display: block;\n width: 100%;\n}\n\n/* Options the author hid and options the search left out. Beats the display\n rule above, which beats the browser's own [hidden] { display: none }. */\n#options-slot::slotted([hidden]),\n#options-slot::slotted([filtered-out]) {\n display: none !important;\n}\n";
|
package/lib/styles/select.js
CHANGED
package/lib/styles/switch.js
CHANGED
|
@@ -16,23 +16,28 @@ ${inputStyles}
|
|
|
16
16
|
:host {
|
|
17
17
|
display: inline-flex;
|
|
18
18
|
width: auto;
|
|
19
|
-
|
|
19
|
+
height: 1.25em;
|
|
20
|
+
height: 1lh;
|
|
21
|
+
align-items: center;
|
|
22
|
+
vertical-align: baseline;
|
|
23
|
+
overflow: visible;
|
|
24
|
+
}
|
|
25
|
+
:host::before {
|
|
26
|
+
content: "\\200b";
|
|
27
|
+
width: 0;
|
|
20
28
|
}
|
|
21
29
|
.switch-container {
|
|
22
30
|
display: inline-flex;
|
|
31
|
+
flex-shrink: 0;
|
|
23
32
|
align-items: center;
|
|
24
|
-
min-height: 1.25em;
|
|
25
|
-
min-height: 1lh;
|
|
26
33
|
cursor: pointer;
|
|
27
34
|
user-select: none;
|
|
28
35
|
outline: none;
|
|
29
|
-
vertical-align: middle;
|
|
30
36
|
}
|
|
31
37
|
.switch-container.disabled {
|
|
32
38
|
cursor: not-allowed;
|
|
33
39
|
opacity: 0.55;
|
|
34
40
|
}
|
|
35
|
-
.switch-container:focus-visible .switch-track,
|
|
36
41
|
.switch-container.focused .switch-track {
|
|
37
42
|
box-shadow: 0 0 0 3px var(--ty-input-shadow-focus);
|
|
38
43
|
}
|
package/lib/styles/tag.js
CHANGED
|
@@ -41,6 +41,14 @@ export interface DayContext {
|
|
|
41
41
|
/** Calendar's selected day (if any) */
|
|
42
42
|
selectedDay?: number;
|
|
43
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Local midnight of a calendar day. Not the Date constructor: it reads years
|
|
46
|
+
* 0-99 as 1900-1999. Noon is never skipped by a clock change, so the day
|
|
47
|
+
* can't shift before setHours.
|
|
48
|
+
*/
|
|
49
|
+
export declare function localMidnight(year: number, month: number, day: number): Date;
|
|
50
|
+
/** UTC midnight of a calendar day, as a timestamp. Not Date.UTC(): it reads years 0-99 as 1900-1999. */
|
|
51
|
+
export declare function utcMidnight(year: number, month: number, day: number): number;
|
|
44
52
|
/**
|
|
45
53
|
* Generate a 42-day calendar grid for a given month
|
|
46
54
|
*
|
|
@@ -109,3 +117,44 @@ export declare function parseISODate(isoString: string): {
|
|
|
109
117
|
month: number;
|
|
110
118
|
day: number;
|
|
111
119
|
} | null;
|
|
120
|
+
/**
|
|
121
|
+
* Date/time components in local time, as ty-date-picker holds them.
|
|
122
|
+
*/
|
|
123
|
+
export interface DateTimeParts {
|
|
124
|
+
year?: number;
|
|
125
|
+
month?: number;
|
|
126
|
+
day?: number;
|
|
127
|
+
hour?: number;
|
|
128
|
+
minute?: number;
|
|
129
|
+
second?: number;
|
|
130
|
+
epoch?: number;
|
|
131
|
+
}
|
|
132
|
+
/** A Date whose UTC fields are these components: formats a wall-clock as written (timeZone 'UTC'). */
|
|
133
|
+
export declare function wallClockDate(c: DateTimeParts): Date;
|
|
134
|
+
/**
|
|
135
|
+
* Parse any date value ty-date-picker reads into local components:
|
|
136
|
+
* - Date only: '2024-09-21' (that calendar day)
|
|
137
|
+
* - Datetime-local: '2024-09-21T10:30[:45]' (local wall-clock)
|
|
138
|
+
* - UTC strings: '2024-09-21T08:30:00Z' or '2024-09-21T08:30:00.000Z'
|
|
139
|
+
* - With timezone: '2024-09-21T10:30:00+02:00'
|
|
140
|
+
* - Timestamps: milliseconds since epoch (a number, not a digit string)
|
|
141
|
+
*
|
|
142
|
+
* A zone-less value is taken as written, even a time the clocks skip when
|
|
143
|
+
* summer time starts (like <input type=datetime-local>); 2024-02-30 or 24:00
|
|
144
|
+
* is no value. An instant reads as its LOCAL day and time, and keeps its
|
|
145
|
+
* epoch.
|
|
146
|
+
*/
|
|
147
|
+
export declare function parseDateTime(value: string | number | null | undefined): DateTimeParts | null;
|
|
148
|
+
/**
|
|
149
|
+
* The calendar day of any value parseDateTime reads, or a Date: an instant
|
|
150
|
+
* is its local day. Years outside 1-9999 (no 4-digit YYYY-MM-DD) are no day.
|
|
151
|
+
*/
|
|
152
|
+
export declare function parseDateDay(value: string | number | Date | null | undefined): {
|
|
153
|
+
year: number;
|
|
154
|
+
month: number;
|
|
155
|
+
day: number;
|
|
156
|
+
} | null;
|
|
157
|
+
/** 'YYYY-MM-DD' of a calendar day (years below 1000 padded to 4 digits). */
|
|
158
|
+
export declare function formatISODay(year: number, month: number, day: number): string;
|
|
159
|
+
/** Any value parseDateDay reads as 'YYYY-MM-DD', or null. */
|
|
160
|
+
export declare function toISODay(value: string | number | Date | null | undefined): string | null;
|
|
@@ -13,8 +13,16 @@ return date1.getFullYear() === date2.getFullYear()
|
|
|
13
13
|
&& date1.getMonth() === date2.getMonth()
|
|
14
14
|
&& date1.getDate() === date2.getDate();
|
|
15
15
|
}
|
|
16
|
-
function
|
|
17
|
-
|
|
16
|
+
export function localMidnight(year, month, day) {
|
|
17
|
+
const date = new Date(2000, 0, 1, 12);
|
|
18
|
+
date.setFullYear(year, month - 1, day);
|
|
19
|
+
date.setHours(0, 0, 0, 0);
|
|
20
|
+
return date;
|
|
21
|
+
}
|
|
22
|
+
export function utcMidnight(year, month, day) {
|
|
23
|
+
const date = new Date(0);
|
|
24
|
+
date.setUTCFullYear(year, month - 1, day);
|
|
25
|
+
return date.getTime();
|
|
18
26
|
}
|
|
19
27
|
function getTodayMidnight() {
|
|
20
28
|
const now = new Date();
|
|
@@ -41,7 +49,7 @@ const isToday = isSameDay(date, todayMidnight);
|
|
|
41
49
|
const isSelected = selection?.year === year
|
|
42
50
|
&& selection?.month === month
|
|
43
51
|
&& selection?.day === dayInMonth;
|
|
44
|
-
const utcValue =
|
|
52
|
+
const utcValue = utcMidnight(year, month, dayInMonth);
|
|
45
53
|
const localValue = date.getTime();
|
|
46
54
|
return {
|
|
47
55
|
value: utcValue,
|
|
@@ -69,7 +77,7 @@ monday.setHours(0, 0, 0, 0);
|
|
|
69
77
|
return monday;
|
|
70
78
|
}
|
|
71
79
|
function getMonthGridStart(year, month) {
|
|
72
|
-
const firstDay =
|
|
80
|
+
const firstDay = localMidnight(year, month, 1);
|
|
73
81
|
return getMondayOfWeek(firstDay);
|
|
74
82
|
}
|
|
75
83
|
function addDays(date, days) {
|
|
@@ -120,9 +128,58 @@ if (month < 1 || month > 12)
|
|
|
120
128
|
return null;
|
|
121
129
|
if (day < 1 || day > 31)
|
|
122
130
|
return null;
|
|
123
|
-
const date = new Date(year, month
|
|
124
|
-
if (date.
|
|
131
|
+
const date = new Date(utcMidnight(year, month, day));
|
|
132
|
+
if (date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) {
|
|
125
133
|
return null;
|
|
126
134
|
}
|
|
127
135
|
return { year, month, day };
|
|
128
136
|
}
|
|
137
|
+
const LOCAL_VALUE = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?)?$/;
|
|
138
|
+
export function wallClockDate(c) {
|
|
139
|
+
const date = new Date(0);
|
|
140
|
+
date.setUTCFullYear(c.year, c.month - 1, c.day);
|
|
141
|
+
date.setUTCHours(c.hour || 0, c.minute || 0, c.second || 0);
|
|
142
|
+
return date;
|
|
143
|
+
}
|
|
144
|
+
export function parseDateTime(value) {
|
|
145
|
+
if (value === null || value === undefined || value === '')
|
|
146
|
+
return null;
|
|
147
|
+
if (typeof value === 'string') {
|
|
148
|
+
const local = LOCAL_VALUE.exec(value);
|
|
149
|
+
if (local) {
|
|
150
|
+
const [year, month, day, hour, minute, second] = local.slice(1, 7).map((g) => Number(g ?? 0));
|
|
151
|
+
const components = { year, month, day, hour, minute, second };
|
|
152
|
+
const check = wallClockDate(components);
|
|
153
|
+
const real = check.getUTCMonth() === month - 1 && check.getUTCDate() === day &&
|
|
154
|
+
hour < 24 && minute < 60 && second < 60;
|
|
155
|
+
return real ? components : null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const dateObj = typeof value === 'number' || typeof value === 'string' ? new Date(value) : null;
|
|
159
|
+
if (!dateObj || isNaN(dateObj.getTime())) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
year: dateObj.getFullYear(),
|
|
164
|
+
month: dateObj.getMonth() + 1,
|
|
165
|
+
day: dateObj.getDate(),
|
|
166
|
+
hour: dateObj.getHours(),
|
|
167
|
+
minute: dateObj.getMinutes(),
|
|
168
|
+
second: dateObj.getSeconds(),
|
|
169
|
+
epoch: dateObj.getTime(),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
export function parseDateDay(value) {
|
|
173
|
+
const parts = parseDateTime(value instanceof Date ? value.getTime() : value);
|
|
174
|
+
if (!parts || parts.year < 1 || parts.year > 9999)
|
|
175
|
+
return null;
|
|
176
|
+
return { year: parts.year, month: parts.month, day: parts.day };
|
|
177
|
+
}
|
|
178
|
+
export function formatISODay(year, month, day) {
|
|
179
|
+
const pad = (n, w) => n.toString().padStart(w, '0');
|
|
180
|
+
return `${pad(year, 4)}-${pad(month, 2)}-${pad(day, 2)}`;
|
|
181
|
+
}
|
|
182
|
+
export function toISODay(value) {
|
|
183
|
+
const d = parseDateDay(value);
|
|
184
|
+
return d && formatISODay(d.year, d.month, d.day);
|
|
185
|
+
}
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Page scroll lock, reference-counted so multiple components can lock at once.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Two parts: overflow: hidden on <html> (the document scroller), and a guard
|
|
5
|
+
* for apps that scroll their own container (a full-height layout with an
|
|
6
|
+
* inner overflow: auto div), which <html> overflow doesn't reach. The guard
|
|
7
|
+
* cancels wheel, touchmove and scroll keys that would scroll anything outside
|
|
8
|
+
* the open dialog they come from (every lock holder is a top-layer <dialog>):
|
|
9
|
+
* scrollers inside the dialog keep working until their edge, nothing chains
|
|
10
|
+
* out of it, and events from the page behind scroll nothing. Keys a control
|
|
11
|
+
* uses itself (text fields, selects, Space on buttons, arrows in widgets) are
|
|
12
|
+
* left alone.
|
|
7
13
|
*/
|
|
8
14
|
declare global {
|
|
9
15
|
interface Window {
|