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.
Files changed (65) hide show
  1. package/css/tyrell-colors-static.css +14 -0
  2. package/css/tyrell-theme.css +1 -0
  3. package/css/tyrell.css +8 -6
  4. package/dist/tyrell-colors-static.css +1 -1
  5. package/dist/tyrell-theme.css +1 -1
  6. package/dist/tyrell.js +1 -1
  7. package/lib/base/ty-component.d.ts +8 -12
  8. package/lib/base/ty-component.js +38 -9
  9. package/lib/components/button.d.ts +10 -0
  10. package/lib/components/button.js +22 -1
  11. package/lib/components/calendar-month.d.ts +7 -1
  12. package/lib/components/calendar-month.js +32 -18
  13. package/lib/components/calendar-navigation.js +19 -6
  14. package/lib/components/calendar.d.ts +24 -5
  15. package/lib/components/calendar.js +83 -37
  16. package/lib/components/checkbox.d.ts +5 -0
  17. package/lib/components/checkbox.js +18 -5
  18. package/lib/components/copy.d.ts +2 -0
  19. package/lib/components/copy.js +15 -31
  20. package/lib/components/date-picker.d.ts +7 -26
  21. package/lib/components/date-picker.js +14 -84
  22. package/lib/components/file-upload.js +6 -2
  23. package/lib/components/input.d.ts +0 -9
  24. package/lib/components/input.js +17 -40
  25. package/lib/components/modal.js +59 -18
  26. package/lib/components/popup.js +56 -5
  27. package/lib/components/radio.d.ts +6 -0
  28. package/lib/components/radio.js +12 -28
  29. package/lib/components/select.d.ts +29 -11
  30. package/lib/components/select.js +43 -57
  31. package/lib/components/switch.d.ts +4 -0
  32. package/lib/components/switch.js +18 -5
  33. package/lib/components/tabs.js +1 -1
  34. package/lib/components/textarea.d.ts +1 -0
  35. package/lib/components/textarea.js +21 -8
  36. package/lib/index.d.ts +1 -0
  37. package/lib/index.js +1 -0
  38. package/lib/styles/button.js +1 -0
  39. package/lib/styles/calendar-month.js +1 -1
  40. package/lib/styles/calendar.js +3 -0
  41. package/lib/styles/checkbox.js +11 -5
  42. package/lib/styles/date-picker.js +5 -10
  43. package/lib/styles/input.js +5 -9
  44. package/lib/styles/option.d.ts +1 -1
  45. package/lib/styles/option.js +4 -0
  46. package/lib/styles/popup.d.ts +1 -1
  47. package/lib/styles/popup.js +2 -0
  48. package/lib/styles/radio.js +10 -4
  49. package/lib/styles/select-base.js +5 -10
  50. package/lib/styles/select.d.ts +1 -1
  51. package/lib/styles/select.js +4 -0
  52. package/lib/styles/switch.js +10 -5
  53. package/lib/styles/tag.js +2 -1
  54. package/lib/utils/calendar-utils.d.ts +49 -0
  55. package/lib/utils/calendar-utils.js +63 -6
  56. package/lib/utils/scroll-lock.d.ts +10 -4
  57. package/lib/utils/scroll-lock.js +102 -2
  58. package/lib/utils/validity.d.ts +27 -1
  59. package/lib/utils/validity.js +68 -16
  60. package/lib/version.d.ts +1 -1
  61. package/lib/version.js +1 -1
  62. package/package.json +1 -1
  63. package/dist/tyrell.js.map +0 -1
  64. package/lib/utils/error-flavor.d.ts +0 -8
  65. package/lib/utils/error-flavor.js +0 -1
@@ -14,14 +14,21 @@ export const checkboxStyles = `
14
14
  :host {
15
15
  display: inline-flex;
16
16
  width: auto;
17
- vertical-align: middle;
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;
@@ -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
  }
@@ -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";
@@ -1,4 +1,8 @@
1
1
  export const optionStyles = `
2
+ :host([hidden]),
3
+ :host([filtered-out]) {
4
+ display: none !important;
5
+ }
2
6
  .option-content {
3
7
  display: flex;
4
8
  align-items: center;
@@ -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";
@@ -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;
@@ -16,7 +16,15 @@ ${inputStyles}
16
16
  :host(ty-radio) {
17
17
  display: inline-flex;
18
18
  width: auto;
19
- vertical-align: middle;
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
  `;
@@ -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";
@@ -215,4 +215,8 @@ border-radius: var(--ty-radius-lg) var(--ty-radius-lg) 0 0;
215
215
  display: block;
216
216
  width: 100%;
217
217
  }
218
+ #options-slot::slotted([hidden]),
219
+ #options-slot::slotted([filtered-out]) {
220
+ display: none !important;
221
+ }
218
222
  `;
@@ -16,23 +16,28 @@ ${inputStyles}
16
16
  :host {
17
17
  display: inline-flex;
18
18
  width: auto;
19
- vertical-align: middle;
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
@@ -28,7 +28,8 @@ box-shadow: 0 0 0 3px var(--ty-color-${f}-faint);
28
28
  }
29
29
  `;
30
30
  export const tagStyles = `
31
- :host([hidden]) {
31
+ :host([hidden]),
32
+ :host([filtered-out]) {
32
33
  display: none !important;
33
34
  }
34
35
  .tag-container {
@@ -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 createMidnightDate(year, month, day) {
17
- return new Date(year, month - 1, day, 0, 0, 0, 0);
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 = Date.UTC(year, month - 1, dayInMonth, 0, 0, 0, 0);
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 = createMidnightDate(year, month, 1);
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 - 1, day);
124
- if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
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
- * CSS-based scroll prevention.
2
+ * Page scroll lock, reference-counted so multiple components can lock at once.
3
3
  *
4
- * Prevents page scrolling via CSS rather than event cancellation — no event
5
- * manipulation, no interference with component keyboard navigation, scroll
6
- * position preserved. Reference-counted so multiple components can lock at once.
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 {