ionbase-ui 0.48.0 → 0.59.0

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 (101) hide show
  1. package/LICENSE +21 -0
  2. package/dist/components/Accordion.d.ts +54 -0
  3. package/dist/components/Accordion.d.ts.map +1 -0
  4. package/dist/components/Accordion.js +68 -0
  5. package/dist/components/Accordion.js.map +1 -0
  6. package/dist/components/Breadcrumb.d.ts +45 -0
  7. package/dist/components/Breadcrumb.d.ts.map +1 -0
  8. package/dist/components/Breadcrumb.js +31 -0
  9. package/dist/components/Breadcrumb.js.map +1 -0
  10. package/dist/components/Checkbox.d.ts +2 -1
  11. package/dist/components/Checkbox.d.ts.map +1 -1
  12. package/dist/components/Checkbox.js +4 -2
  13. package/dist/components/Checkbox.js.map +1 -1
  14. package/dist/components/Combobox.d.ts +86 -0
  15. package/dist/components/Combobox.d.ts.map +1 -0
  16. package/dist/components/Combobox.js +261 -0
  17. package/dist/components/Combobox.js.map +1 -0
  18. package/dist/components/Drawer.d.ts +52 -0
  19. package/dist/components/Drawer.d.ts.map +1 -0
  20. package/dist/components/Drawer.js +66 -0
  21. package/dist/components/Drawer.js.map +1 -0
  22. package/dist/components/FileUpload.d.ts +79 -0
  23. package/dist/components/FileUpload.d.ts.map +1 -0
  24. package/dist/components/FileUpload.js +218 -0
  25. package/dist/components/FileUpload.js.map +1 -0
  26. package/dist/components/ProgressBar.d.ts +46 -0
  27. package/dist/components/ProgressBar.d.ts.map +1 -0
  28. package/dist/components/ProgressBar.js +52 -0
  29. package/dist/components/ProgressBar.js.map +1 -0
  30. package/dist/components/Select.d.ts +9 -2
  31. package/dist/components/Select.d.ts.map +1 -1
  32. package/dist/components/Select.js.map +1 -1
  33. package/dist/components/Skeleton.d.ts +39 -0
  34. package/dist/components/Skeleton.d.ts.map +1 -0
  35. package/dist/components/Skeleton.js +50 -0
  36. package/dist/components/Skeleton.js.map +1 -0
  37. package/dist/components/Spinner.d.ts +45 -0
  38. package/dist/components/Spinner.d.ts.map +1 -0
  39. package/dist/components/Spinner.js +44 -0
  40. package/dist/components/Spinner.js.map +1 -0
  41. package/dist/components/Textarea.d.ts +50 -0
  42. package/dist/components/Textarea.d.ts.map +1 -0
  43. package/dist/components/Textarea.js +48 -0
  44. package/dist/components/Textarea.js.map +1 -0
  45. package/dist/components/Toggle.d.ts +2 -1
  46. package/dist/components/Toggle.d.ts.map +1 -1
  47. package/dist/components/Toggle.js +4 -2
  48. package/dist/components/Toggle.js.map +1 -1
  49. package/dist/components/index.d.ts +18 -0
  50. package/dist/components/index.d.ts.map +1 -1
  51. package/dist/components/index.js +9 -0
  52. package/dist/components/index.js.map +1 -1
  53. package/dist/components/resolve-selection.d.ts +36 -0
  54. package/dist/components/resolve-selection.d.ts.map +1 -0
  55. package/dist/components/resolve-selection.js +14 -0
  56. package/dist/components/resolve-selection.js.map +1 -0
  57. package/dist/figma-descriptions.json +204 -144
  58. package/dist/figma-map.json +2311 -1643
  59. package/dist/meta/Accordion.json +123 -0
  60. package/dist/meta/AccordionItem.json +90 -0
  61. package/dist/meta/Breadcrumb.json +87 -0
  62. package/dist/meta/BreadcrumbItem.json +78 -0
  63. package/dist/meta/Checkbox.json +18 -1
  64. package/dist/meta/Combobox.json +265 -0
  65. package/dist/meta/Drawer.json +234 -0
  66. package/dist/meta/EmptyState.json +1 -1
  67. package/dist/meta/FileUpload.json +246 -0
  68. package/dist/meta/Input.json +0 -1
  69. package/dist/meta/PhoneInput.json +39 -39
  70. package/dist/meta/Popover.json +3 -3
  71. package/dist/meta/ProgressBar.json +166 -0
  72. package/dist/meta/Select.json +4 -3
  73. package/dist/meta/Skeleton.json +120 -0
  74. package/dist/meta/Spinner.json +123 -0
  75. package/dist/meta/Textarea.json +520 -0
  76. package/dist/meta/Toggle.json +18 -1
  77. package/dist/meta/Tooltip.json +3 -3
  78. package/dist/meta/components.json +2447 -361
  79. package/dist/meta/contrast.json +3130 -227
  80. package/dist/meta/index.json +149 -7
  81. package/dist/meta/patterns/index.json +1 -1
  82. package/dist/styles/accordion.css +102 -0
  83. package/dist/styles/breadcrumb.css +72 -0
  84. package/dist/styles/combobox.css +154 -0
  85. package/dist/styles/drawer.css +181 -0
  86. package/dist/styles/empty-state.css +16 -4
  87. package/dist/styles/file-upload.css +236 -0
  88. package/dist/styles/index.css +9 -0
  89. package/dist/styles/input.css +34 -15
  90. package/dist/styles/progress-bar.css +107 -0
  91. package/dist/styles/skeleton.css +76 -0
  92. package/dist/styles/spinner.css +70 -0
  93. package/dist/styles/textarea.css +178 -0
  94. package/dist/styles/tokens/base.css +0 -1
  95. package/dist/styles/tokens/theme-dark.css +0 -1
  96. package/dist/tokens/index.d.ts +0 -6
  97. package/dist/tokens/index.d.ts.map +1 -1
  98. package/dist/tokens/index.js +0 -5
  99. package/dist/tokens/index.js.map +1 -1
  100. package/llms.txt +2 -2
  101. package/package.json +19 -19
@@ -0,0 +1,181 @@
1
+ /*
2
+ * Drawer
3
+ *
4
+ * Drawn in Figma as `Drawer` (1365:2404), from this file. Twelve variants:
5
+ * four placements by three sizes, the same cross product the props expose.
6
+ *
7
+ * The panel IS the block — `.ion-drawer`, not `.ion-drawer__panel` — which
8
+ * matches popover.css and is load-bearing rather than cosmetic. The contrast
9
+ * gate resolves a translucent hover colour's backdrop from the block's own
10
+ * background, so with the surface on an element class the close button's hover
11
+ * pairing came back "skipped": unmeasured, and silently so.
12
+ *
13
+ * The scrim lives here for the same reason it lives in modal.css: nothing can
14
+ * render an overlay without also rendering what sits behind it, so the split
15
+ * Figma makes between panel and overlay has no meaning in code.
16
+ *
17
+ * Placement uses logical properties throughout — `inset-inline-start` rather
18
+ * than `left` — so `start` and `end` follow writing direction and a right-hand
19
+ * drawer in English is a left-hand one in Arabic without a second variant.
20
+ */
21
+ .ion-drawer__scrim {
22
+ position: fixed;
23
+ inset: 0;
24
+ z-index: 1000;
25
+ display: flex;
26
+ background-color: var(--surface-scrim);
27
+ }
28
+
29
+ .ion-drawer {
30
+ --ion-drawer-size: 30rem;
31
+
32
+ position: fixed;
33
+ display: flex;
34
+ flex-direction: column;
35
+ max-width: 100%;
36
+ max-height: 100%;
37
+ background-color: var(--surface-raised);
38
+ color: var(--text-default);
39
+ font-family: var(--font-family-sans);
40
+ }
41
+
42
+ .ion-drawer--sm {
43
+ --ion-drawer-size: 22rem;
44
+ }
45
+
46
+ .ion-drawer--lg {
47
+ --ion-drawer-size: 40rem;
48
+ }
49
+
50
+ /* Edge panels are square against their own edge and rounded on the inner one:
51
+ * a drawer rounded on all four corners reads as a floating card that happens
52
+ * to touch the side. */
53
+ .ion-drawer--start,
54
+ .ion-drawer--end {
55
+ inset-block: 0;
56
+ width: var(--ion-drawer-size);
57
+ }
58
+
59
+ .ion-drawer--start {
60
+ inset-inline-start: 0;
61
+ border-start-end-radius: var(--radius-lg);
62
+ border-end-end-radius: var(--radius-lg);
63
+ }
64
+
65
+ .ion-drawer--end {
66
+ inset-inline-end: 0;
67
+ border-start-start-radius: var(--radius-lg);
68
+ border-end-start-radius: var(--radius-lg);
69
+ }
70
+
71
+ .ion-drawer--top,
72
+ .ion-drawer--bottom {
73
+ inset-inline: 0;
74
+ height: var(--ion-drawer-size);
75
+ width: 100%;
76
+ }
77
+
78
+ .ion-drawer--top {
79
+ inset-block-start: 0;
80
+ border-end-start-radius: var(--radius-lg);
81
+ border-end-end-radius: var(--radius-lg);
82
+ }
83
+
84
+ .ion-drawer--bottom {
85
+ inset-block-end: 0;
86
+ border-start-start-radius: var(--radius-lg);
87
+ border-start-end-radius: var(--radius-lg);
88
+ }
89
+
90
+ .ion-drawer__header {
91
+ display: flex;
92
+ align-items: flex-start;
93
+ justify-content: space-between;
94
+ gap: var(--spacing-12);
95
+ flex: none;
96
+ padding: var(--spacing-24);
97
+ border-bottom: var(--border-width-default) solid var(--border-subtle);
98
+ }
99
+
100
+ .ion-drawer__heading {
101
+ display: flex;
102
+ flex-direction: column;
103
+ gap: var(--spacing-4);
104
+ min-width: 0;
105
+ }
106
+
107
+ .ion-drawer__title {
108
+ margin: 0;
109
+ font-size: var(--type-h5);
110
+ line-height: var(--type-h5-line-height);
111
+ font-weight: var(--font-weight-semibold);
112
+ }
113
+
114
+ .ion-drawer__description {
115
+ margin: 0;
116
+ color: var(--text-secondary);
117
+ font-size: var(--type-body-sm);
118
+ line-height: var(--type-body-sm-line-height);
119
+ }
120
+
121
+ .ion-drawer__close {
122
+ display: inline-flex;
123
+ align-items: center;
124
+ justify-content: center;
125
+ flex: none;
126
+ width: var(--spacing-32);
127
+ height: var(--spacing-32);
128
+ padding: var(--spacing-4);
129
+ border: 0;
130
+ border-radius: var(--radius-sm);
131
+ background: none;
132
+ color: var(--icon-secondary);
133
+ cursor: pointer;
134
+ }
135
+
136
+ /*
137
+ * Background only, no colour change — the same rule Modal's close button uses,
138
+ * and for a measurable reason as well as a consistency one. `surface/hover` is
139
+ * translucent, so a rule that changes BOTH creates a foreground/background pair
140
+ * whose backdrop the contrast gate cannot resolve, and it reports the pairing
141
+ * as skipped. A skipped pairing is an unmeasured one.
142
+ */
143
+ @media (hover: hover) {
144
+ .ion-drawer__close:hover {
145
+ background-color: var(--surface-hover);
146
+ }
147
+ }
148
+
149
+ .ion-drawer__close:focus-visible {
150
+ outline: var(--border-width-thick) solid var(--border-focus);
151
+ outline-offset: 2px;
152
+ }
153
+
154
+ .ion-drawer__close svg {
155
+ width: var(--icon-size-md);
156
+ height: var(--icon-size-md);
157
+ }
158
+
159
+ /*
160
+ * The body is the only scrolling region. A drawer whose whole panel scrolls
161
+ * takes its own title off screen, which is the one piece of context a user
162
+ * needs while working down a long list.
163
+ */
164
+ .ion-drawer__body {
165
+ flex: 1;
166
+ min-height: 0;
167
+ overflow-y: auto;
168
+ padding: var(--spacing-24);
169
+ font-size: var(--type-body);
170
+ line-height: var(--type-body-line-height);
171
+ }
172
+
173
+ .ion-drawer__footer {
174
+ display: flex;
175
+ align-items: center;
176
+ justify-content: flex-end;
177
+ gap: var(--spacing-8);
178
+ flex: none;
179
+ padding: var(--spacing-24);
180
+ border-top: var(--border-width-default) solid var(--border-subtle);
181
+ }
@@ -1,9 +1,11 @@
1
1
  /*
2
2
  * EmptyState
3
3
  *
4
- * Repo-owned, not measured from Figma — there is no Empty State component in
5
- * the file yet. Every value below is a token already on a ladder, so when one
6
- * is drawn the two can be reconciled without moving anything underneath.
4
+ * Drawn in Figma on 15 Sep 2026, as `Empty State` — 12 variants, Size x Reason.
5
+ * It was the last component in this package with no Figma counterpart, and it
6
+ * went the unusual direction: built here first from the pattern tier's
7
+ * requirement, then drawn to match. Every value was already a token on a
8
+ * ladder, which is what made the reconciliation cost one line.
7
9
  *
8
10
  * REASON CHANGES THE ICON COLOUR AND NOTHING ELSE
9
11
  *
@@ -117,7 +119,17 @@
117
119
  color: var(--text-default);
118
120
  font-size: var(--ion-empty-state-title);
119
121
  line-height: var(--ion-empty-state-title-line-height);
120
- font-weight: var(--font-weight-medium);
122
+
123
+ /*
124
+ * Semibold, not medium, settled when the component was drawn. Every heading
125
+ * style in the Figma file — `Heading/H3`, `Heading/H4` — is semibold, so a
126
+ * medium title here would have been the only heading-sized text in the system
127
+ * off the ramp, and would have needed a text style that exists nowhere else.
128
+ * The `inline` size sits at body-md rather than a heading rung, and its style
129
+ * (`Body/Medium Semibold`) was added to the file alongside the component, in
130
+ * the same shape as the `Body/Default Semibold` already there.
131
+ */
132
+ font-weight: var(--font-weight-semibold);
121
133
 
122
134
  /* A filter value or record id in the title must wrap, not widen the region. */
123
135
  overflow-wrap: anywhere;
@@ -0,0 +1,236 @@
1
+ /*
2
+ * FileUpload
3
+ *
4
+ * Drawn in Figma as `File Upload` (1367:2333), from this file. Nothing here was
5
+ * invented: the border, radius, background and disabled treatment are
6
+ * `Input`'s, and the centred icon-over-text stack with its padding ladder is
7
+ * `EmptyState`'s. The bet when this was written was that drawing it would move
8
+ * values and not structure; drawing it moved neither.
9
+ *
10
+ * Figma draws the DROP ZONE only, not the label and helper around it — the same
11
+ * split `Input` makes against `Form Field`. The file list has no Figma
12
+ * counterpart at all, because it is runtime content rather than a state.
13
+ *
14
+ * THE VISUALLY HIDDEN INPUT IS NOT `display: none`
15
+ *
16
+ * A `display: none` input leaves the tab order and the accessibility tree, and
17
+ * the component then has no control in it at all — which is how the common
18
+ * build of this component ends up keyboard-unreachable while looking identical.
19
+ * `.ion-visually-hidden` would work, but the input is also positioned to sit
20
+ * inside the zone so a browser can anchor its own validation bubble somewhere
21
+ * sensible rather than at the top-left of the page.
22
+ */
23
+ .ion-file-upload {
24
+ --ion-file-upload-padding: var(--spacing-32);
25
+ --ion-file-upload-gap: var(--spacing-8);
26
+ --ion-file-upload-icon-size: var(--icon-size-lg);
27
+
28
+ position: relative;
29
+ display: flex;
30
+ background-color: var(--surface-default);
31
+ border: var(--border-width-default) dashed var(--border-default);
32
+ border-radius: var(--radius-md);
33
+ transition:
34
+ border-color var(--ion-duration-base) var(--ion-ease-out),
35
+ background-color var(--ion-duration-base) var(--ion-ease-out);
36
+ }
37
+
38
+ .ion-file-upload--sm {
39
+ --ion-file-upload-padding: var(--spacing-20);
40
+ --ion-file-upload-gap: var(--spacing-6);
41
+ --ion-file-upload-icon-size: var(--icon-size-md);
42
+ }
43
+
44
+ /*
45
+ * 1px, not 0. The input has to have a box for the focus ring to be drawn
46
+ * against and for a validation bubble to point at; collapsing it to nothing
47
+ * puts both in the document corner. It is clipped, so the 1px is never seen.
48
+ */
49
+ .ion-file-upload__input {
50
+ position: absolute;
51
+ bottom: var(--spacing-8);
52
+ left: 50%;
53
+ width: 1px;
54
+ height: 1px;
55
+ padding: 0;
56
+ overflow: hidden;
57
+ clip-path: inset(50%);
58
+ border: 0;
59
+ }
60
+
61
+ .ion-file-upload__target {
62
+ display: flex;
63
+ flex: 1 1 auto;
64
+ flex-direction: column;
65
+ align-items: center;
66
+ justify-content: center;
67
+ gap: var(--ion-file-upload-gap);
68
+ padding: var(--ion-file-upload-padding) var(--spacing-24);
69
+ text-align: center;
70
+ cursor: pointer;
71
+ }
72
+
73
+ .ion-file-upload__icon {
74
+ display: inline-flex;
75
+ color: var(--icon-secondary);
76
+ }
77
+
78
+ .ion-file-upload__icon svg {
79
+ width: var(--ion-file-upload-icon-size);
80
+ height: var(--ion-file-upload-icon-size);
81
+ }
82
+
83
+ .ion-file-upload__prompt {
84
+ color: var(--text-default);
85
+ font-family: var(--font-family-sans);
86
+ font-size: var(--type-body);
87
+ line-height: var(--type-body-line-height);
88
+ font-weight: var(--font-weight-medium);
89
+ }
90
+
91
+ .ion-file-upload__hint {
92
+ color: var(--text-tertiary);
93
+ font-family: var(--font-family-sans);
94
+ font-size: var(--type-body-sm);
95
+ line-height: var(--type-body-sm-line-height);
96
+ }
97
+
98
+ @media (hover: hover) {
99
+ .ion-file-upload:hover {
100
+ border-color: var(--border-strong);
101
+ background-color: var(--surface-hover);
102
+ }
103
+ }
104
+
105
+ /*
106
+ * The focus ring is on the ZONE, driven by the hidden input's focus. The ring
107
+ * belongs where the user's eye is, and the input is one clipped pixel.
108
+ */
109
+ .ion-file-upload:focus-within {
110
+ outline: var(--border-width-thick) solid var(--ring-focus);
111
+ outline-offset: 2px;
112
+ }
113
+
114
+ /*
115
+ * Dragging reads as a solid border, not a colour change alone. A drop target
116
+ * that only shifts hue is invisible to anyone who cannot distinguish the two —
117
+ * WCAG 1.4.1, and it is also just easier to see.
118
+ */
119
+ .ion-file-upload--dragging,
120
+ .ion-file-upload[data-dragging='true'] {
121
+ border-style: solid;
122
+ border-color: var(--border-focus);
123
+ background-color: var(--surface-primary-subtle);
124
+ }
125
+
126
+ .ion-file-upload--invalid,
127
+ .ion-file-upload[data-invalid='true'] {
128
+ border-color: var(--border-error);
129
+ }
130
+
131
+ .ion-file-upload--disabled,
132
+ .ion-file-upload[data-disabled='true'] {
133
+ background-color: var(--surface-disabled);
134
+ border-color: var(--border-disabled);
135
+ }
136
+
137
+ .ion-file-upload--disabled .ion-file-upload__target,
138
+ .ion-file-upload[data-disabled='true'] .ion-file-upload__target {
139
+ cursor: not-allowed;
140
+ }
141
+
142
+ .ion-file-upload--disabled .ion-file-upload__prompt,
143
+ .ion-file-upload[data-disabled='true'] .ion-file-upload__prompt {
144
+ color: var(--text-disabled);
145
+ }
146
+
147
+ .ion-file-upload--disabled .ion-file-upload__icon,
148
+ .ion-file-upload[data-disabled='true'] .ion-file-upload__icon {
149
+ color: var(--icon-disabled);
150
+ }
151
+
152
+ /*
153
+ * The list of held files is a sibling of the zone, not a child of it.
154
+ *
155
+ * Inside the zone it would sit inside the `<label>`, which makes every row —
156
+ * including each remove button — a click target for the file picker. Removing
157
+ * a file would open the picker on the way out.
158
+ */
159
+ .ion-file-upload__list {
160
+ display: flex;
161
+ flex-direction: column;
162
+ gap: var(--spacing-6);
163
+ margin: 0;
164
+ padding: 0;
165
+ list-style: none;
166
+ }
167
+
168
+ .ion-file-upload__file {
169
+ display: flex;
170
+ align-items: center;
171
+ gap: var(--spacing-8);
172
+ min-height: var(--spacing-32);
173
+ padding-inline: var(--spacing-12);
174
+ background-color: var(--surface-subtle);
175
+ border-radius: var(--radius-sm);
176
+ font-family: var(--font-family-sans);
177
+ font-size: var(--type-body-sm);
178
+ line-height: var(--type-body-sm-line-height);
179
+ }
180
+
181
+ .ion-file-upload__name {
182
+ flex: 1 1 auto;
183
+ min-width: 0;
184
+ overflow: hidden;
185
+ color: var(--text-default);
186
+ text-overflow: ellipsis;
187
+ white-space: nowrap;
188
+ }
189
+
190
+ .ion-file-upload__size {
191
+ flex-shrink: 0;
192
+ color: var(--text-tertiary);
193
+ font-variant-numeric: tabular-nums;
194
+ }
195
+
196
+ /*
197
+ * 24px square — WCAG 2.2 SC 2.5.8's minimum pointer target, around a 16px
198
+ * glyph. Do not shrink it to the icon.
199
+ */
200
+ .ion-file-upload__remove {
201
+ display: inline-flex;
202
+ align-items: center;
203
+ justify-content: center;
204
+ flex-shrink: 0;
205
+ width: var(--spacing-24);
206
+ height: var(--spacing-24);
207
+ padding: 0;
208
+ background: none;
209
+ border: none;
210
+ border-radius: var(--radius-sm);
211
+ color: var(--icon-secondary);
212
+ cursor: pointer;
213
+ transition: background-color var(--ion-duration-base) var(--ion-ease-out);
214
+ }
215
+
216
+ .ion-file-upload__remove svg {
217
+ width: var(--icon-size-sm);
218
+ height: var(--icon-size-sm);
219
+ }
220
+
221
+ @media (hover: hover) {
222
+ .ion-file-upload__remove:hover {
223
+ background-color: var(--surface-hover);
224
+ color: var(--icon-default);
225
+ }
226
+ }
227
+
228
+ .ion-file-upload__remove:focus-visible {
229
+ outline: var(--border-width-thick) solid var(--ring-focus);
230
+ outline-offset: 2px;
231
+ }
232
+
233
+ .ion-file-upload__remove:disabled {
234
+ color: var(--icon-disabled);
235
+ cursor: not-allowed;
236
+ }
@@ -32,6 +32,7 @@
32
32
  @import url('./alert.css');
33
33
  @import url('./badge.css');
34
34
  @import url('./input.css');
35
+ @import url('./textarea.css');
35
36
  @import url('./phone-input.css');
36
37
  @import url('./select.css');
37
38
  @import url('./checkbox.css');
@@ -66,6 +67,14 @@
66
67
  @import url('./citation.css');
67
68
  @import url('./confidence-indicator.css');
68
69
  @import url('./empty-state.css');
70
+ @import url('./spinner.css');
71
+ @import url('./progress-bar.css');
72
+ @import url('./skeleton.css');
73
+ @import url('./breadcrumb.css');
74
+ @import url('./accordion.css');
75
+ @import url('./drawer.css');
76
+ @import url('./file-upload.css');
77
+ @import url('./combobox.css');
69
78
 
70
79
  /*
71
80
  * Visually hidden, but not hidden from assistive tech.
@@ -21,10 +21,12 @@
21
21
  * padding box, so `padding-x: 12px` would put the text at 13px. The border
22
22
  * width is subtracted back out — the same correction Badge and Tabs needed.
23
23
  *
24
- * This also makes the focus state free: Figma's focus ring is a 2px inside
25
- * stroke, which eats 1px further into the padding and leaves the text
26
- * exactly where it was. Subtracting the live border width reproduces that,
27
- * so nothing shifts when the field takes focus.
24
+ * The subtraction is against the LIVE border width, which used to matter:
25
+ * focus was a 2px inside stroke that ate a further 1px of padding, and
26
+ * subtracting it kept the text from shifting. Every state is 1px as of
27
+ * 5 Sep 2026, so the correction is now constant — but it stays written this
28
+ * way, because it is correct either way and the next state to need a
29
+ * different width gets it for free.
28
30
  */
29
31
  .ion-input {
30
32
  /*
@@ -172,9 +174,10 @@
172
174
  * hover -> focus -> invalid -> read-only -> disabled.
173
175
  *
174
176
  * Invalid sits after focus and overrides the colour only, never the width. A
175
- * focused invalid field therefore keeps the 2px ring but shows it in the error
176
- * colour, which is both states being true at once rather than one masking the
177
- * other.
177
+ * focused invalid field therefore shows the error colour rather than the focus
178
+ * colour — both states are true at once and the more urgent one is shown. That
179
+ * ordering mattered more when the two differed in width as well; it is still
180
+ * the behaviour, and still deliberate.
178
181
  */
179
182
 
180
183
  /*
@@ -192,23 +195,39 @@
192
195
  border-color: var(--border-strong);
193
196
  }
194
197
 
198
+ /*
199
+ * 1px, not the 2px this used to carry. See the note on --invalid below: both
200
+ * states were thinned on the same design call, and the trade is the same one.
201
+ */
195
202
  .ion-input:focus-within,
196
203
  .ion-input[data-focused='true'] {
197
- --ion-input-border-width: var(--border-width-thick);
198
-
199
204
  border-color: var(--border-focus);
200
205
  }
201
206
 
202
207
  /*
203
- * 2px, matching Focus and matching Select. At 1px the invalid state differed
204
- * from default only in hue, which is the exact failure mode WCAG 1.4.1 is
205
- * about — error has to be perceivable without relying on colour alone.
206
- * Reconciled in Figma; Input was the one that was wrong.
208
+ * 1px. THIS COMMENT USED TO ARGUE FOR 2px AND THE ARGUMENT WAS SOUND, so read
209
+ * it before changing anything back:
210
+ *
211
+ * "2px, matching Focus and matching Select. At 1px the invalid state differed
212
+ * from default only in hue, which is the exact failure mode WCAG 1.4.1 is
213
+ * about — error has to be perceivable without relying on colour alone.
214
+ * Reconciled in Figma; Input was the one that was wrong."
215
+ *
216
+ * That defect was found once and fixed once, and thinning the border reopens
217
+ * it: an invalid field now differs from a default field by hue alone. It was
218
+ * changed on an explicit design call on 5 Sep 2026, applied to Input and
219
+ * Textarea and to their Figma sets, and it is recorded here rather than left to
220
+ * look like drift.
221
+ *
222
+ * Select, Checkbox, Radio, Tabs and Menu still carry border-width/thick, so
223
+ * these two are deliberately out of step with the rest of the system.
224
+ *
225
+ * IF THE ERROR STATE NEEDS A NON-COLOUR CUE AGAIN, this is the decision to
226
+ * revisit — and an icon or a message would satisfy 1.4.1 without the border,
227
+ * which is the better fix than putting the 2px back.
207
228
  */
208
229
  .ion-input--invalid,
209
230
  .ion-input[data-invalid='true'] {
210
- --ion-input-border-width: var(--border-width-thick);
211
-
212
231
  border-color: var(--border-error-strong);
213
232
  }
214
233
 
@@ -0,0 +1,107 @@
1
+ /*
2
+ * ProgressBar
3
+ *
4
+ * The fill width arrives as a custom property rather than an inline `width`,
5
+ * so the only inline value a caller ever sees is a percentage on a token-named
6
+ * property — and `no-raw-style-values` stays true rather than exempted.
7
+ */
8
+ .ion-progress-bar {
9
+ --ion-progress-bar-height: var(--spacing-8);
10
+ --ion-progress-bar-fill: var(--surface-primary);
11
+ --ion-progress-bar-pct: 0%;
12
+
13
+ display: flex;
14
+ flex-direction: column;
15
+ gap: var(--spacing-4);
16
+ font-family: var(--font-family-sans);
17
+ }
18
+
19
+ .ion-progress-bar--sm {
20
+ --ion-progress-bar-height: var(--spacing-4);
21
+ }
22
+
23
+ .ion-progress-bar--success {
24
+ --ion-progress-bar-fill: var(--surface-success);
25
+ }
26
+
27
+ .ion-progress-bar--warning {
28
+ --ion-progress-bar-fill: var(--surface-warning);
29
+ }
30
+
31
+ .ion-progress-bar--error {
32
+ --ion-progress-bar-fill: var(--surface-error);
33
+ }
34
+
35
+ .ion-progress-bar__header {
36
+ display: flex;
37
+ align-items: baseline;
38
+ justify-content: space-between;
39
+ gap: var(--spacing-8);
40
+ font-size: var(--type-body-sm);
41
+ line-height: var(--type-body-sm-line-height);
42
+ }
43
+
44
+ .ion-progress-bar__label {
45
+ color: var(--text-default);
46
+ }
47
+
48
+ .ion-progress-bar__value {
49
+ color: var(--text-secondary);
50
+ font-variant-numeric: tabular-nums;
51
+ }
52
+
53
+ .ion-progress-bar__track {
54
+ overflow: hidden;
55
+ height: var(--ion-progress-bar-height);
56
+ background-color: var(--surface-muted);
57
+ border-radius: var(--radius-full);
58
+ }
59
+
60
+ .ion-progress-bar__fill {
61
+ width: var(--ion-progress-bar-pct);
62
+ height: 100%;
63
+ background-color: var(--ion-progress-bar-fill);
64
+ border-radius: inherit;
65
+ transition: width var(--ion-duration-base) var(--ion-ease-out);
66
+ }
67
+
68
+ /*
69
+ * Indeterminate is a travelling segment rather than a filling bar: a bar that
70
+ * fills and resets reads as repeated failure and restart.
71
+ */
72
+ .ion-progress-bar--indeterminate .ion-progress-bar__fill {
73
+ width: 40%;
74
+ animation: ion-progress-bar-slide var(--ion-duration-slow)
75
+ var(--ion-ease-in-out) infinite;
76
+ }
77
+
78
+ @keyframes ion-progress-bar-slide {
79
+ from {
80
+ transform: translateX(-100%);
81
+ }
82
+
83
+ to {
84
+ transform: translateX(250%);
85
+ }
86
+ }
87
+
88
+ @media (prefers-reduced-motion: reduce) {
89
+ .ion-progress-bar__fill {
90
+ transition-duration: 1ms;
91
+ }
92
+
93
+ /* Still moving, still slow — see Spinner for the same reasoning. */
94
+ .ion-progress-bar--indeterminate .ion-progress-bar__fill {
95
+ animation-duration: 3000ms;
96
+ }
97
+ }
98
+
99
+ @media (forced-colors: active) {
100
+ .ion-progress-bar__track {
101
+ border: var(--border-width-default) solid CanvasText;
102
+ }
103
+
104
+ .ion-progress-bar__fill {
105
+ background-color: Highlight;
106
+ }
107
+ }