tyrell-components 1.0.0-TC47 → 1.0.0-TC49

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 (41) hide show
  1. package/css/tyrell-theme.css +88 -92
  2. package/css/tyrell.css +19 -0
  3. package/dist/tyrell-theme.css +88 -92
  4. package/dist/tyrell.css +19 -0
  5. package/dist/tyrell.js +1 -1
  6. package/lib/base/ty-component.d.ts +8 -3
  7. package/lib/base/ty-component.js +13 -5
  8. package/lib/components/date-picker.js +2 -8
  9. package/lib/components/file-upload.d.ts +6 -0
  10. package/lib/components/file-upload.js +19 -4
  11. package/lib/components/input.js +1 -0
  12. package/lib/components/modal.d.ts +12 -0
  13. package/lib/components/modal.js +56 -13
  14. package/lib/components/popup.js +4 -4
  15. package/lib/components/radio.js +10 -2
  16. package/lib/components/resize-observer.d.ts +1 -0
  17. package/lib/components/resize-observer.js +12 -1
  18. package/lib/components/select.js +3 -5
  19. package/lib/components/selected-tags.js +1 -1
  20. package/lib/components/tabs.d.ts +2 -0
  21. package/lib/components/tabs.js +15 -0
  22. package/lib/components/tooltip.js +22 -0
  23. package/lib/components/wizard.d.ts +2 -0
  24. package/lib/components/wizard.js +15 -0
  25. package/lib/styles/button.js +4 -1
  26. package/lib/styles/date-picker.js +27 -1
  27. package/lib/styles/modal.d.ts +1 -1
  28. package/lib/styles/modal.js +18 -0
  29. package/lib/styles/option.d.ts +1 -1
  30. package/lib/styles/option.js +15 -0
  31. package/lib/styles/popup.d.ts +1 -1
  32. package/lib/styles/popup.js +22 -5
  33. package/lib/styles/select-base.js +53 -5
  34. package/lib/styles/select.d.ts +1 -1
  35. package/lib/styles/select.js +5 -1
  36. package/lib/utils/number-format.d.ts +6 -1
  37. package/lib/utils/number-format.js +9 -2
  38. package/lib/utils/positioning.js +4 -4
  39. package/lib/version.d.ts +1 -1
  40. package/lib/version.js +1 -1
  41. package/package.json +1 -1
@@ -4,4 +4,4 @@
4
4
  * CSS for the modal component wrapper.
5
5
  * The modal is a pure wrapper - all content styling is user-defined.
6
6
  */
7
- export declare const modalStyles = "\n:host {\n display: contents;\n /* Don't interfere with layout */\n}\n\n/* Part of the modal wrapper functionality, not user content styling */\n.close-button {\n position: absolute;\n top: -8px;\n right: -8px;\n background: transparent;\n border: none;\n cursor: pointer;\n padding: 4px;\n border-radius: 4px;\n transition: var(--ty-transition-colors);\n z-index: 1;\n color: #d8d8d8;\n\n /* Remove all default browser styling that could cause outlines/borders */\n outline: none;\n box-shadow: none;\n -webkit-appearance: none;\n -moz-appearance: none;\n appearance: none;\n\n /* Remove mobile tap highlights and focus rings */\n -webkit-tap-highlight-color: transparent;\n -webkit-touch-callout: none;\n -webkit-user-select: none;\n -moz-user-select: none;\n -ms-user-select: none;\n user-select: none;\n}\n\n.close-button:hover {\n color: white;\n}\n\n/* Explicitly remove focus and active states for all interaction modes */\n.close-button:focus,\n.close-button:focus-visible,\n.close-button:focus-within,\n.close-button:active {\n outline: none !important;\n box-shadow: none !important;\n border: none !important;\n background: transparent !important;\n}\n\n/* Override hover color even in focus/active states */\n.close-button:hover,\n.close-button:focus:hover,\n.close-button:active:hover {\n color: white;\n}\n\n.close-button.hide {\n visibility: hidden;\n}\n\n/* Pure wrapper: only essential modal functionality, no styling of user content */\n.ty-modal-dialog {\n /* Reset default dialog styles */\n padding: 18px;\n border: none;\n margin: 0;\n background: transparent;\n\n /* Centered */\n position: fixed;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n\n z-index: 1000;\n overflow: hidden;\n\n /* No sizing - let user content determine size */\n max-width: 85vw;\n max-height: 90vh;\n\n outline: none;\n}\n\n/* Backdrop is part of modal functionality, not user content */\n.ty-modal-dialog::backdrop {\n background: var(--ty-modal-backdrop, rgba(0, 0, 0, 0.5));\n backdrop-filter: var(--ty-modal-backdrop-blur, blur(2px));\n animation: ty-modal-backdrop-enter var(--ty-modal-duration, 200ms) ease-out;\n}\n\n/* When backdrop is disabled */\n.ty-modal-dialog:not([data-backdrop])::backdrop {\n background: transparent;\n backdrop-filter: none;\n}\n\n/* Minimal container - no styling imposed on user content */\n.modal-content {\n color: var(--ty-text);\n}\n\n@keyframes ty-modal-backdrop-enter {\n from {\n opacity: 0;\n }\n\n to {\n opacity: 1;\n }\n}\n";
7
+ export declare const modalStyles = "\n:host {\n display: contents;\n /* Don't interfere with layout */\n}\n\n/* Part of the modal wrapper functionality, not user content styling */\n.close-button {\n position: absolute;\n top: -8px;\n right: -8px;\n background: transparent;\n border: none;\n cursor: pointer;\n padding: 4px;\n border-radius: 4px;\n transition: var(--ty-transition-colors);\n z-index: 1;\n color: #d8d8d8;\n\n /* Remove all default browser styling that could cause outlines/borders */\n outline: none;\n box-shadow: none;\n -webkit-appearance: none;\n -moz-appearance: none;\n appearance: none;\n\n /* Remove mobile tap highlights and focus rings */\n -webkit-tap-highlight-color: transparent;\n -webkit-touch-callout: none;\n -webkit-user-select: none;\n -moz-user-select: none;\n -ms-user-select: none;\n user-select: none;\n}\n\n.close-button:hover {\n color: white;\n}\n\n/* Explicitly remove focus and active states for all interaction modes */\n.close-button:focus,\n.close-button:focus-visible,\n.close-button:focus-within,\n.close-button:active {\n outline: none !important;\n box-shadow: none !important;\n border: none !important;\n background: transparent !important;\n}\n\n/* Override hover color even in focus/active states */\n.close-button:hover,\n.close-button:focus:hover,\n.close-button:active:hover {\n color: white;\n}\n\n.close-button.hide {\n visibility: hidden;\n}\n\n/* Pure wrapper: only essential modal functionality, no styling of user content */\n.ty-modal-dialog {\n /* Reset default dialog styles */\n padding: 18px;\n border: none;\n margin: 0;\n background: transparent;\n\n /* Centered */\n position: fixed;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n\n z-index: 1000;\n overflow: hidden;\n\n /* No sizing - let user content determine size */\n max-width: 85vw;\n max-height: 90vh;\n\n outline: none;\n}\n\n/* Panel zoom-in synced with the backdrop fade (shadcn-style zoom-in-95).\n Enter-only: animating exit on a native <dialog> means delaying close(). */\n.ty-modal-dialog[open] {\n animation: ty-modal-enter var(--ty-modal-duration, 200ms) ease-out;\n}\n\n@keyframes ty-modal-enter {\n from {\n opacity: 0;\n transform: translate(-50%, -50%) scale(0.93);\n }\n\n to {\n opacity: 1;\n transform: translate(-50%, -50%) scale(1);\n }\n}\n\n/* Backdrop is part of modal functionality, not user content */\n.ty-modal-dialog::backdrop {\n background: var(--ty-modal-backdrop, rgba(0, 0, 0, 0.5));\n backdrop-filter: var(--ty-modal-backdrop-blur, blur(2px));\n animation: ty-modal-backdrop-enter var(--ty-modal-duration, 200ms) ease-out;\n}\n\n/* When backdrop is disabled */\n.ty-modal-dialog:not([data-backdrop])::backdrop {\n background: transparent;\n backdrop-filter: none;\n}\n\n/* Minimal container - no styling imposed on user content */\n.modal-content {\n color: var(--ty-text);\n}\n\n@keyframes ty-modal-backdrop-enter {\n from {\n opacity: 0;\n }\n\n to {\n opacity: 1;\n }\n}\n";
@@ -84,6 +84,24 @@ export const modalStyles = `
84
84
  outline: none;
85
85
  }
86
86
 
87
+ /* Panel zoom-in synced with the backdrop fade (shadcn-style zoom-in-95).
88
+ Enter-only: animating exit on a native <dialog> means delaying close(). */
89
+ .ty-modal-dialog[open] {
90
+ animation: ty-modal-enter var(--ty-modal-duration, 200ms) ease-out;
91
+ }
92
+
93
+ @keyframes ty-modal-enter {
94
+ from {
95
+ opacity: 0;
96
+ transform: translate(-50%, -50%) scale(0.93);
97
+ }
98
+
99
+ to {
100
+ opacity: 1;
101
+ transform: translate(-50%, -50%) scale(1);
102
+ }
103
+ }
104
+
87
105
  /* Backdrop is part of modal functionality, not user content */
88
106
  .ty-modal-dialog::backdrop {
89
107
  background: var(--ty-modal-backdrop, rgba(0, 0, 0, 0.5));
@@ -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:host([cloned]) .option-content {\n padding: 0px;\n background: transparent;\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 font-weight: var(--ty-font-semibold);\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, visible only on selected options */\n.option-check {\n display: none;\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 display: block;\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.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: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}\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 font-weight: var(--ty-font-semibold);\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, visible only on selected options */\n.option-check {\n display: none;\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 display: block;\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";
@@ -22,9 +22,24 @@ export const optionStyles = `
22
22
  border-radius: var(--ty-radius-md);
23
23
  }
24
24
 
25
+ /* Cloned copy (projected into the select's trigger field): host defaults to
26
+ inline like any unstyled custom element, which ignores width/overflow —
27
+ force a block box so the parent's flex-basis:0 truncation (select.ts
28
+ .select-stub.has-clone slot[name="selected"]) actually clips it, and
29
+ collapse the content to a single ellipsized line instead of wrapping. */
30
+ :host([cloned]) {
31
+ display: block;
32
+ min-width: 0;
33
+ overflow: hidden;
34
+ }
25
35
  :host([cloned]) .option-content {
36
+ display: block;
26
37
  padding: 0px;
27
38
  background: transparent;
39
+ min-width: 0;
40
+ overflow: hidden;
41
+ white-space: nowrap;
42
+ text-overflow: ellipsis;
28
43
  }
29
44
 
30
45
  .option-content:hover {
@@ -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 150ms ease-out,\n visibility 150ms ease-out,\n transform 150ms ease-out;\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/* Apply scale for entrance animation (right before .open) */\n.popup-dialog.preparing-animation {\n transform: scale(0.95);\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\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(0.96);\n}\n\n.popup-dialog[data-side=\"bottom\"].preparing-animation:not(.open) {\n transform: translateY(-6px) scale(0.96);\n}\n\n.popup-dialog[data-side=\"top\"].preparing-animation:not(.open) {\n transform: translateY(6px) scale(0.96);\n}\n\n.popup-dialog[data-side=\"left\"].preparing-animation:not(.open) {\n transform: translateX(6px) scale(0.96);\n}\n\n.popup-dialog[data-side=\"right\"].preparing-animation:not(.open) {\n transform: translateX(-6px) 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";
@@ -20,9 +20,9 @@ export const popupStyles = `
20
20
  /* NO TRANSFORM here - apply only when animating to avoid measurement errors! */
21
21
 
22
22
  transition:
23
- opacity 150ms ease-out,
24
- visibility 150ms ease-out,
25
- transform 150ms ease-out;
23
+ opacity var(--ty-popup-duration, 180ms) var(--ty-popup-ease, cubic-bezier(0.34, 1.56, 0.64, 1)),
24
+ visibility var(--ty-popup-duration, 180ms) var(--ty-popup-ease, cubic-bezier(0.34, 1.56, 0.64, 1)),
25
+ transform var(--ty-popup-duration, 180ms) var(--ty-popup-ease, cubic-bezier(0.34, 1.56, 0.64, 1));
26
26
 
27
27
  /* Prevent interactions when hidden */
28
28
  pointer-events: none;
@@ -34,9 +34,26 @@ export const popupStyles = `
34
34
  left: var(--popup-x, 0px);
35
35
  }
36
36
 
37
- /* Apply scale for entrance animation (right before .open) */
37
+ /* Entrance prep (right before .open): scale + a short slide from the anchor
38
+ side. data-side is the RESOLVED side (post-flip), set by updatePosition. */
38
39
  .popup-dialog.preparing-animation {
39
- transform: scale(0.95);
40
+ transform: scale(0.96);
41
+ }
42
+
43
+ .popup-dialog[data-side="bottom"].preparing-animation:not(.open) {
44
+ transform: translateY(-6px) scale(0.96);
45
+ }
46
+
47
+ .popup-dialog[data-side="top"].preparing-animation:not(.open) {
48
+ transform: translateY(6px) scale(0.96);
49
+ }
50
+
51
+ .popup-dialog[data-side="left"].preparing-animation:not(.open) {
52
+ transform: translateX(6px) scale(0.96);
53
+ }
54
+
55
+ .popup-dialog[data-side="right"].preparing-animation:not(.open) {
56
+ transform: translateX(-6px) scale(0.96);
40
57
  }
41
58
 
42
59
  .popup-dialog.open {
@@ -57,7 +57,17 @@ export const selectBaseStyles = `
57
57
  /* Modal handles z-index automatically */
58
58
 
59
59
  opacity: 0;
60
- transition: opacity 400ms ease;
60
+ /* Panel-level entrance: individual scale/translate compose with the
61
+ collision-shift transform below. The single-select skin flattens the
62
+ inner .dropdown-options animation (select.ts: "panel appears as one"),
63
+ so the dialog carries the motion for both skins. */
64
+ scale: 0.96;
65
+ translate: 0 -6px;
66
+ transform-origin: top center;
67
+ transition:
68
+ opacity var(--ty-popup-duration, 180ms) var(--ty-popup-ease, cubic-bezier(0.34, 1.56, 0.64, 1)),
69
+ scale var(--ty-popup-duration, 180ms) var(--ty-popup-ease, cubic-bezier(0.34, 1.56, 0.64, 1)),
70
+ translate var(--ty-popup-duration, 180ms) var(--ty-popup-ease, cubic-bezier(0.34, 1.56, 0.64, 1));
61
71
 
62
72
  transform: translate(var(--dropdown-offset-x, 0px), var(--dropdown-offset-y, 0px));
63
73
  top: -1000px;
@@ -80,6 +90,8 @@ export const selectBaseStyles = `
80
90
  bottom: var(--dropdown-y);
81
91
  top: auto;
82
92
  flex-direction: column-reverse;
93
+ translate: 0 6px;
94
+ transform-origin: bottom center;
83
95
  }
84
96
 
85
97
  .dropdown-dialog.position-above .dropdown-header {
@@ -94,8 +106,15 @@ export const selectBaseStyles = `
94
106
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1), var(--ty-shadow-md);
95
107
  }
96
108
 
109
+ .dropdown-dialog.position-above .dropdown-options {
110
+ transform: translateY(6px) scale(0.96);
111
+ transform-origin: bottom center;
112
+ }
113
+
97
114
  .dropdown-dialog.open {
98
115
  opacity: 1;
116
+ scale: 1;
117
+ translate: 0 0;
99
118
  }
100
119
 
101
120
  .dropdown-dialog.open .dropdown-options {
@@ -103,6 +122,31 @@ export const selectBaseStyles = `
103
122
  transform: translateY(0) scale(1);
104
123
  }
105
124
 
125
+ /* Transitions never start on an element that was display:none last frame —
126
+ showModal() + .open land in the same frame, so the enter transition was
127
+ silently skipped. @starting-style supplies the first-frame "from" values
128
+ (this is the platform mechanism for animating dialog/top-layer entry). */
129
+ @starting-style {
130
+ .dropdown-dialog.open {
131
+ opacity: 0;
132
+ scale: 0.96;
133
+ translate: 0 -6px;
134
+ }
135
+
136
+ .dropdown-dialog.open.position-above {
137
+ translate: 0 6px;
138
+ }
139
+
140
+ .dropdown-dialog.open .dropdown-options {
141
+ opacity: 0;
142
+ transform: translateY(-6px) scale(0.96);
143
+ }
144
+
145
+ .dropdown-dialog.open.position-above .dropdown-options {
146
+ transform: translateY(6px) scale(0.96);
147
+ }
148
+ }
149
+
106
150
  .dropdown-dialog::backdrop {
107
151
  background: transparent;
108
152
  }
@@ -327,8 +371,11 @@ export const selectBaseStyles = `
327
371
  min-width: 0;
328
372
  }
329
373
 
374
+ /* No flex-grow here — the placeholder only ever renders inside .select-stub,
375
+ and select.ts sets flex: 1 1 0% on it. A bare flex-grow: 1 (implicit
376
+ flex-basis:auto) is the wrap bug that fix exists to prevent; leaving it
377
+ here would silently reintroduce it if the specific rule ever moved. */
330
378
  .dropdown-placeholder {
331
- flex-grow: 1;
332
379
  color: var(--input-placeholder, var(--ty-input-placeholder, #9ca3af));
333
380
  font-size: var(--ty-font-sm);
334
381
  line-height: var(--ty-leading-sm);
@@ -364,10 +411,11 @@ export const selectBaseStyles = `
364
411
  0 20px 25px -5px rgba(0, 0, 0, 0.1),
365
412
  0 10px 10px -5px rgba(0, 0, 0, 0.04);
366
413
 
367
- transform: translateY(-8px) scale(0.95);
414
+ transform: translateY(-6px) scale(0.96);
415
+ transform-origin: top center;
368
416
  transition:
369
- opacity 200ms cubic-bezier(0.16, 1, 0.3, 1),
370
- transform 200ms cubic-bezier(0.16, 1, 0.3, 1);
417
+ opacity var(--ty-popup-duration, 180ms) var(--ty-popup-ease, cubic-bezier(0.34, 1.56, 0.64, 1)),
418
+ transform var(--ty-popup-duration, 180ms) var(--ty-popup-ease, cubic-bezier(0.34, 1.56, 0.64, 1));
371
419
 
372
420
  display: flex;
373
421
  flex-wrap: wrap;
@@ -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.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: 1 1 auto;\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.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";
@@ -110,7 +110,11 @@ export const selectStyles = `
110
110
  }
111
111
  .select-stub.has-clone slot[name="selected"] {
112
112
  display: block;
113
- flex: 1 1 auto;
113
+ /* flex-basis 0, not auto — same reasoning as .dropdown-placeholder above:
114
+ .select-stub is flex-wrap:wrap, and auto's hypothetical size is the
115
+ clone's full untruncated text width, which pushes .select-clear onto
116
+ a second line. */
117
+ flex: 1 1 0%;
114
118
  min-width: 0;
115
119
  overflow: hidden;
116
120
  }
@@ -33,6 +33,10 @@ export declare function formatNumber(value: number, config: FormatConfig): strin
33
33
  * Simple rule: the last occurring . or , is the decimal separator.
34
34
  * Everything else is stripped. This works for both US (1,234.56) and
35
35
  * European (1.234,56) input, and for mobile keyboards that use , as decimal.
36
+ * One grouping exception: a repeated separator ("1.234.567") can't be a
37
+ * decimal point, so it's stripped as thousands grouping. A LONE separator is
38
+ * always decimal — "$1,234" pasted from formatted text parses as 1.234; this
39
+ * known ambiguity is resolved in favor of typed input ("12,50" → 12.5).
36
40
  *
37
41
  * @example
38
42
  * ```typescript
@@ -41,7 +45,8 @@ export declare function formatNumber(value: number, config: FormatConfig): strin
41
45
  * parseNumericValue("12,50") // 12.5 (last separator is ,)
42
46
  * parseNumericValue("12.50") // 12.5 (last separator is .)
43
47
  * parseNumericValue("1234") // 1234
44
- * parseNumericValue("$1,234") // 1234
48
+ * parseNumericValue("1.234.567") // 1234567 (repeated separator = grouping)
49
+ * parseNumericValue("$1,234") // 1.234 (lone separator = decimal; known paste ambiguity)
45
50
  * parseNumericValue("15%") // 15
46
51
  * parseNumericValue("") // null
47
52
  * ```
@@ -43,8 +43,15 @@ export function parseNumericValue(value) {
43
43
  const parsed = parseFloat(stripped);
44
44
  return isNaN(parsed) ? null : parsed;
45
45
  }
46
- const intPart = stripped.slice(0, lastSep).replace(/[.,]/g, '');
47
- const decPart = stripped.slice(lastSep + 1);
46
+ const sepChar = stripped[lastSep];
47
+ const before = stripped.slice(0, lastSep);
48
+ const after = stripped.slice(lastSep + 1);
49
+ if (before.includes(sepChar)) {
50
+ const parsed = parseFloat(stripped.replace(/[.,]/g, ''));
51
+ return isNaN(parsed) ? null : parsed;
52
+ }
53
+ const intPart = before.replace(/[.,]/g, '');
54
+ const decPart = after;
48
55
  const parsed = parseFloat(intPart + '.' + decPart);
49
56
  return isNaN(parsed) ? null : parsed;
50
57
  }
@@ -140,13 +140,13 @@ function calculatePlacement(options) {
140
140
  }
141
141
  else {
142
142
  if (horizontal === 'start') {
143
- x = targetRect.left;
143
+ x = targetRect.left - containerPadding;
144
144
  }
145
145
  else if (horizontal === 'center') {
146
146
  x = targetRect.centerX - floatingRect.width / 2;
147
147
  }
148
148
  else {
149
- x = targetRect.right - floatingRect.width;
149
+ x = targetRect.right - floatingRect.width + containerPadding;
150
150
  }
151
151
  }
152
152
  let y;
@@ -155,10 +155,10 @@ function calculatePlacement(options) {
155
155
  y = targetRect.centerY - floatingRect.height / 2;
156
156
  }
157
157
  else if (vertical === 'end') {
158
- y = targetRect.bottom - floatingRect.height - containerPadding;
158
+ y = targetRect.bottom - floatingRect.height + containerPadding;
159
159
  }
160
160
  else {
161
- y = targetRect.top + containerPadding;
161
+ y = targetRect.top - containerPadding;
162
162
  }
163
163
  }
164
164
  else {
package/lib/version.d.ts CHANGED
@@ -4,4 +4,4 @@
4
4
  * This is automatically synced with package.json during build.
5
5
  * Use `npm version` to bump versions.
6
6
  */
7
- export declare const VERSION = "1.0.0-TC47";
7
+ export declare const VERSION = "1.0.0-TC49";
package/lib/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '1.0.0-TC47';
1
+ export const VERSION = '1.0.0-TC49';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tyrell-components",
3
- "version": "1.0.0-TC47",
3
+ "version": "1.0.0-TC49",
4
4
  "description": "Tyrell Components - Framework-agnostic web components with semantic design system",
5
5
  "type": "module",
6
6
  "sideEffects": [