ionbase-ui 0.1.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 (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -0
  3. package/dist/Icon.d.ts +72 -0
  4. package/dist/Icon.d.ts.map +1 -0
  5. package/dist/Icon.js +50 -0
  6. package/dist/Icon.js.map +1 -0
  7. package/dist/components/Avatar.d.ts +52 -0
  8. package/dist/components/Avatar.d.ts.map +1 -0
  9. package/dist/components/Avatar.js +59 -0
  10. package/dist/components/Avatar.js.map +1 -0
  11. package/dist/components/Badge.d.ts +26 -0
  12. package/dist/components/Badge.d.ts.map +1 -0
  13. package/dist/components/Badge.js +17 -0
  14. package/dist/components/Badge.js.map +1 -0
  15. package/dist/components/Button.d.ts +18 -0
  16. package/dist/components/Button.d.ts.map +1 -0
  17. package/dist/components/Button.js +43 -0
  18. package/dist/components/Button.js.map +1 -0
  19. package/dist/components/Checkbox.d.ts +30 -0
  20. package/dist/components/Checkbox.d.ts.map +1 -0
  21. package/dist/components/Checkbox.js +39 -0
  22. package/dist/components/Checkbox.js.map +1 -0
  23. package/dist/components/Divider.d.ts +17 -0
  24. package/dist/components/Divider.d.ts.map +1 -0
  25. package/dist/components/Divider.js +16 -0
  26. package/dist/components/Divider.js.map +1 -0
  27. package/dist/components/Header.d.ts +25 -0
  28. package/dist/components/Header.d.ts.map +1 -0
  29. package/dist/components/Header.js +18 -0
  30. package/dist/components/Header.js.map +1 -0
  31. package/dist/components/Input.d.ts +32 -0
  32. package/dist/components/Input.d.ts.map +1 -0
  33. package/dist/components/Input.js +66 -0
  34. package/dist/components/Input.js.map +1 -0
  35. package/dist/components/Logo.d.ts +41 -0
  36. package/dist/components/Logo.d.ts.map +1 -0
  37. package/dist/components/Logo.js +70 -0
  38. package/dist/components/Logo.js.map +1 -0
  39. package/dist/components/Menu.d.ts +29 -0
  40. package/dist/components/Menu.d.ts.map +1 -0
  41. package/dist/components/Menu.js +31 -0
  42. package/dist/components/Menu.js.map +1 -0
  43. package/dist/components/NavItem.d.ts +30 -0
  44. package/dist/components/NavItem.d.ts.map +1 -0
  45. package/dist/components/NavItem.js +45 -0
  46. package/dist/components/NavItem.js.map +1 -0
  47. package/dist/components/Radio.d.ts +30 -0
  48. package/dist/components/Radio.d.ts.map +1 -0
  49. package/dist/components/Radio.js +61 -0
  50. package/dist/components/Radio.js.map +1 -0
  51. package/dist/components/ScrollProgress.d.ts +33 -0
  52. package/dist/components/ScrollProgress.d.ts.map +1 -0
  53. package/dist/components/ScrollProgress.js +115 -0
  54. package/dist/components/ScrollProgress.js.map +1 -0
  55. package/dist/components/Select.d.ts +43 -0
  56. package/dist/components/Select.d.ts.map +1 -0
  57. package/dist/components/Select.js +80 -0
  58. package/dist/components/Select.js.map +1 -0
  59. package/dist/components/Table.d.ts +71 -0
  60. package/dist/components/Table.d.ts.map +1 -0
  61. package/dist/components/Table.js +60 -0
  62. package/dist/components/Table.js.map +1 -0
  63. package/dist/components/Tabs.d.ts +23 -0
  64. package/dist/components/Tabs.d.ts.map +1 -0
  65. package/dist/components/Tabs.js +47 -0
  66. package/dist/components/Tabs.js.map +1 -0
  67. package/dist/components/Toggle.d.ts +25 -0
  68. package/dist/components/Toggle.d.ts.map +1 -0
  69. package/dist/components/Toggle.js +32 -0
  70. package/dist/components/Toggle.js.map +1 -0
  71. package/dist/components/index.d.ts +33 -0
  72. package/dist/components/index.d.ts.map +1 -0
  73. package/dist/components/index.js +17 -0
  74. package/dist/components/index.js.map +1 -0
  75. package/dist/index.d.ts +25 -0
  76. package/dist/index.d.ts.map +1 -0
  77. package/dist/index.js +24 -0
  78. package/dist/index.js.map +1 -0
  79. package/dist/styles/avatar.css +138 -0
  80. package/dist/styles/badge.css +125 -0
  81. package/dist/styles/button.css +331 -0
  82. package/dist/styles/checkbox.css +229 -0
  83. package/dist/styles/divider.css +31 -0
  84. package/dist/styles/header.css +95 -0
  85. package/dist/styles/index.css +44 -0
  86. package/dist/styles/input.css +264 -0
  87. package/dist/styles/logo.css +63 -0
  88. package/dist/styles/menu.css +150 -0
  89. package/dist/styles/nav-item.css +120 -0
  90. package/dist/styles/radio.css +200 -0
  91. package/dist/styles/scroll-progress.css +171 -0
  92. package/dist/styles/select.css +162 -0
  93. package/dist/styles/table.css +192 -0
  94. package/dist/styles/tabs.css +219 -0
  95. package/dist/styles/toggle.css +184 -0
  96. package/dist/styles/tokens/base.css +386 -0
  97. package/dist/styles/tokens/breakpoint-mobile.css +34 -0
  98. package/dist/styles/tokens/breakpoint-tablet.css +25 -0
  99. package/dist/styles/tokens/elevation.css +112 -0
  100. package/dist/styles/tokens/index.css +6 -0
  101. package/dist/styles/tokens/theme-dark.css +106 -0
  102. package/dist/styles/tokens/typography.css +124 -0
  103. package/dist/tokens/index.d.ts +2097 -0
  104. package/dist/tokens/index.d.ts.map +1 -0
  105. package/dist/tokens/index.js +1702 -0
  106. package/dist/tokens/index.js.map +1 -0
  107. package/package.json +70 -0
@@ -0,0 +1,229 @@
1
+ /*
2
+ * Checkbox
3
+ *
4
+ * Measured from Figma `Checkbox` (54 variants: 3 colours x 3 sizes x 6 states).
5
+ *
6
+ * The indicator is a sibling of the label, not a wrapper, because Figma lays
7
+ * them out as a row with a gap. The native input is visually hidden rather than
8
+ * replaced: it keeps the control focusable, announceable and form-associated,
9
+ * and the styled square follows it via :checked / :focus-visible.
10
+ *
11
+ * THE MARK IS DERIVED
12
+ *
13
+ * The mark is 12 / 14 / 16 against boxes of 16 / 20 / 24. 14 is on no ladder —
14
+ * both spacing and icon-size skip it — but the mark is not arbitrary:
15
+ *
16
+ * mark = box / 2 + 4 16->12 20->14 24->16
17
+ *
18
+ * So the only number stated per size is the box, and nothing here reaches for
19
+ * the raw `scale/*` ramp. Same move as the toggle track and the avatar overlap:
20
+ * an off-ladder value is usually a question about what it is derived from.
21
+ *
22
+ * FOCUS RING
23
+ *
24
+ * Figma draws a separate `Focus Ring` frame 2px larger than the indicator on
25
+ * every side (16 -> 20, 20 -> 24, 24 -> 28). A CSS outline does exactly that at
26
+ * `outline-offset: 0`, so the ring needs no element of its own and cannot
27
+ * disturb the row's layout — which a real 24px sibling would.
28
+ */
29
+ .ion-checkbox {
30
+ --ion-checkbox-size: var(--spacing-20);
31
+ --ion-checkbox-gap: var(--spacing-8);
32
+ --ion-checkbox-radius: var(--radius-xs);
33
+ --ion-checkbox-font-size: var(--type-body);
34
+ --ion-checkbox-line-height: var(--type-body-line-height);
35
+
36
+ /* Filled by the intent modifier; unchecked is intent-independent. */
37
+ --ion-checkbox-fill: var(--surface-primary);
38
+ --ion-checkbox-border: var(--border-primary-strong);
39
+
40
+ /*
41
+ * The checked box is bevelled, not flat. `Raised/Flush/*` — flush, with no
42
+ * cast shadow, because a 16-24px control sits ON the surface rather than
43
+ * above it. Indexed by size like Button's, and identical across all three
44
+ * intents: Figma varies only the fill.
45
+ */
46
+ --ion-checkbox-raised: var(--ion-shadow-raised-flush-sm);
47
+
48
+ /* Derived — see the header. */
49
+ --ion-checkbox-mark: calc(var(--ion-checkbox-size) / 2 + var(--spacing-4));
50
+
51
+ display: inline-flex;
52
+ align-items: flex-start;
53
+ gap: var(--ion-checkbox-gap);
54
+ font-family: var(--font-family-sans);
55
+ font-size: var(--ion-checkbox-font-size);
56
+ line-height: var(--ion-checkbox-line-height);
57
+ cursor: pointer;
58
+ }
59
+
60
+ .ion-checkbox--sm {
61
+ --ion-checkbox-size: var(--spacing-16);
62
+ --ion-checkbox-font-size: var(--type-body-sm);
63
+ --ion-checkbox-line-height: var(--type-body-sm-line-height);
64
+ --ion-checkbox-raised: var(--ion-shadow-raised-flush-xs);
65
+ }
66
+
67
+ /* Large is the only size that widens the gap, and the only one with a larger
68
+ * corner — Figma moves it from radius/xs to radius/sm at 24px. */
69
+ .ion-checkbox--lg {
70
+ --ion-checkbox-size: var(--spacing-24);
71
+ --ion-checkbox-gap: var(--spacing-12);
72
+ --ion-checkbox-radius: var(--radius-sm);
73
+ --ion-checkbox-raised: var(--ion-shadow-raised-flush-lg);
74
+ }
75
+
76
+ /*
77
+ * The native input carries every bit of behaviour and none of the appearance.
78
+ * Kept in the layout (not `display: none`) so it stays focusable and reachable
79
+ * by assistive tech; the indicator beside it renders the state.
80
+ */
81
+ .ion-checkbox__input {
82
+ position: absolute;
83
+ width: 1px;
84
+ height: 1px;
85
+ margin: 0;
86
+ padding: 0;
87
+ border: none;
88
+ opacity: 0;
89
+ pointer-events: none;
90
+ }
91
+
92
+ .ion-checkbox__indicator {
93
+ display: inline-flex;
94
+ align-items: center;
95
+ justify-content: center;
96
+ flex-shrink: 0;
97
+ width: var(--ion-checkbox-size);
98
+ height: var(--ion-checkbox-size);
99
+ background-color: var(--surface-default);
100
+ border-style: solid;
101
+
102
+ /*
103
+ * The EMPTY box carries the heavier border: 2px `border/stronger`, where the
104
+ * filled box drops to 1px of its intent colour. An unchecked checkbox is
105
+ * nothing but its outline, so it has to hold the row on its own; once filled,
106
+ * the fill does that work and a thinner edge stops it reading as ringed.
107
+ *
108
+ * Figma strokes this INSIDE, and `box-sizing: border-box` (set globally in
109
+ * index.css) is what reproduces that — the box stays 16/20/24 and the border
110
+ * eats inward. Without it, switching to 2px would grow every checkbox by 2px.
111
+ *
112
+ * Radio matches this exactly; the two controls stay in step.
113
+ */
114
+ border-width: var(--border-width-thick);
115
+ border-color: var(--border-stronger);
116
+ border-radius: var(--ion-checkbox-radius);
117
+
118
+ /*
119
+ * Centred on the FIRST LINE of the label, not the whole block, so a wrapping
120
+ * label keeps its box beside line one. The label's line box is taller than
121
+ * the indicator at every size, so the offset is half the difference.
122
+ */
123
+ margin-top: calc(
124
+ (var(--ion-checkbox-line-height) - var(--ion-checkbox-size)) / 2
125
+ );
126
+ transition:
127
+ background-color 150ms cubic-bezier(0.4, 0, 0.2, 1),
128
+ border-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
129
+ }
130
+
131
+ .ion-checkbox__mark {
132
+ width: var(--ion-checkbox-mark);
133
+ height: var(--ion-checkbox-mark);
134
+ color: var(--icon-on-color);
135
+
136
+ /* Hidden rather than unmounted so the box never reflows when it appears. */
137
+ opacity: 0;
138
+ }
139
+
140
+ .ion-checkbox__mark svg {
141
+ display: block;
142
+ width: 100%;
143
+ height: 100%;
144
+ }
145
+
146
+ .ion-checkbox__label {
147
+ color: var(--text-secondary);
148
+ user-select: none;
149
+ }
150
+
151
+ /* Checked and indeterminate share one appearance — Figma differs only in the mark. */
152
+ .ion-checkbox__input:checked + .ion-checkbox__indicator,
153
+ .ion-checkbox__input:indeterminate + .ion-checkbox__indicator {
154
+ /* Back to 1px — see the indicator's border comment. */
155
+ border-width: var(--border-width-default);
156
+ background-color: var(--ion-checkbox-fill);
157
+ border-color: var(--ion-checkbox-border);
158
+
159
+ /* Only the filled box is bevelled; an empty box stays flat. */
160
+ box-shadow: var(--ion-checkbox-raised);
161
+ }
162
+
163
+ .ion-checkbox__input:checked + .ion-checkbox__indicator .ion-checkbox__mark,
164
+ .ion-checkbox__input:indeterminate
165
+ + .ion-checkbox__indicator
166
+ .ion-checkbox__mark {
167
+ opacity: 1;
168
+ }
169
+
170
+ /* Intent modifiers. Only the checked fill and border differ. */
171
+ .ion-checkbox--neutral {
172
+ --ion-checkbox-fill: var(--surface-inverse);
173
+ --ion-checkbox-border: var(--border-inverse);
174
+ }
175
+
176
+ .ion-checkbox--danger {
177
+ --ion-checkbox-fill: var(--surface-error);
178
+ --ion-checkbox-border: var(--border-error-strong);
179
+ }
180
+
181
+ /*
182
+ * See the header: the ring is an outline, so it grows outside the box without
183
+ * taking part in layout. 2px at offset 0 reproduces Figma's +2px-per-side frame.
184
+ */
185
+ .ion-checkbox__input:focus-visible + .ion-checkbox__indicator,
186
+ .ion-checkbox[data-focused='true'] .ion-checkbox__indicator {
187
+ outline: var(--border-width-thick) solid var(--border-focus);
188
+ outline-offset: 0;
189
+ }
190
+
191
+ /*
192
+ * Disabled is driven off the input's own `:disabled`, not off a class on the
193
+ * wrapper, because it has to BEAT `:checked`.
194
+ *
195
+ * `.ion-checkbox--disabled .ion-checkbox__indicator` is (0,2,0) while
196
+ * `.ion-checkbox__input:checked + .ion-checkbox__indicator` is (0,3,0), so a
197
+ * disabled checked box would have kept its filled appearance no matter where
198
+ * the rule sat in the file. Matching `:checked:disabled` is (0,4,0) and wins.
199
+ */
200
+ .ion-checkbox__input:disabled + .ion-checkbox__indicator,
201
+ .ion-checkbox__input:checked:disabled + .ion-checkbox__indicator,
202
+ .ion-checkbox__input:indeterminate:disabled + .ion-checkbox__indicator {
203
+ background-color: var(--surface-disabled);
204
+ border-color: var(--border-disabled);
205
+
206
+ /*
207
+ * 2px in all three disabled states, checked included — measured. So this has
208
+ * to restate the width to beat `:checked`, which sets 1px; the compound
209
+ * `:checked:disabled` selector is what makes it win, not source order.
210
+ */
211
+ border-width: var(--border-width-thick);
212
+
213
+ /* A disabled box is flat whether or not it is checked. */
214
+ box-shadow: none;
215
+ }
216
+
217
+ .ion-checkbox__input:disabled + .ion-checkbox__indicator .ion-checkbox__mark {
218
+ color: var(--icon-disabled);
219
+ }
220
+
221
+ .ion-checkbox--disabled,
222
+ .ion-checkbox[data-disabled='true'] {
223
+ cursor: not-allowed;
224
+ }
225
+
226
+ .ion-checkbox--disabled .ion-checkbox__label,
227
+ .ion-checkbox[data-disabled='true'] .ion-checkbox__label {
228
+ color: var(--text-disabled);
229
+ }
@@ -0,0 +1,31 @@
1
+ /*
2
+ * Divider
3
+ *
4
+ * Measured from Figma `Border` (70:22153) — a single 1px line, `border/strong`
5
+ * rather than `border/default`. That is the one surprising fact here: every
6
+ * other hairline in the system (Input, Table row rules, Menu) reads
7
+ * `border/default`. A divider is drawn at a stronger weight deliberately —
8
+ * its only job is to separate, so it is allowed to be the most visible line
9
+ * in the system rather than competing with content-carrying borders.
10
+ *
11
+ * Horizontal and vertical are one component in Figma (`Style` variant), and
12
+ * one class here: a fixed 1px cross-axis with the main axis left to `100%`,
13
+ * since Figma's 80-unit length is canvas demo filler, not a real constraint.
14
+ */
15
+ .ion-divider {
16
+ flex-shrink: 0;
17
+ background-color: var(--border-strong);
18
+ border: none;
19
+ margin: 0;
20
+ }
21
+
22
+ .ion-divider--horizontal {
23
+ width: 100%;
24
+ height: var(--border-width-default);
25
+ }
26
+
27
+ .ion-divider--vertical {
28
+ width: var(--border-width-default);
29
+ height: 100%;
30
+ align-self: stretch;
31
+ }
@@ -0,0 +1,95 @@
1
+ /*
2
+ * Header
3
+ *
4
+ * Measured from Figma `Header` (70:22088). Three device variants:
5
+ *
6
+ * Desktop 56 tall, padding 8 / 64, end-slot gap 12
7
+ * Tablet 64 tall, padding 12 / 32, end-slot gap 8
8
+ * Mobile 64 tall, padding 12 / 16, end-slot gap 8
9
+ *
10
+ * WHY THIS IS MEDIA QUERIES AND NOT A PROP
11
+ *
12
+ * Device is the one variant axis in the whole system that the browser already
13
+ * knows the answer to. Every other component takes its variant from the caller
14
+ * because the caller is the only one who knows; a header is Mobile because the
15
+ * viewport is narrow, and asking React to duplicate that is how the two get out
16
+ * of step. The breakpoints match the Breakpoint collection's own container
17
+ * widths — Tablet below 1216, Mobile below 896.
18
+ *
19
+ * Note the header is taller on Tablet and Mobile than on Desktop (64 vs 56).
20
+ * That looks inverted but is deliberate in the design: touch targets need the
21
+ * height more than the desktop pointer does.
22
+ */
23
+ .ion-header {
24
+ --ion-header-height: var(--spacing-64);
25
+ --ion-header-padding-y: var(--spacing-12);
26
+ --ion-header-padding-x: var(--spacing-16);
27
+ --ion-header-end-gap: var(--spacing-8);
28
+
29
+ display: flex;
30
+ align-items: center;
31
+ justify-content: space-between;
32
+ gap: var(--spacing-8);
33
+ width: 100%;
34
+ min-height: var(--ion-header-height);
35
+ padding: var(--ion-header-padding-y) var(--ion-header-padding-x);
36
+ background-color: var(--surface-default);
37
+
38
+ /*
39
+ * A bottom rule only. Figma reports the stroke weight as mixed because just
40
+ * the bottom edge carries one, which is exactly what a header divider is.
41
+ */
42
+ border-bottom: var(--border-width-default) solid var(--border-default);
43
+ font-family: var(--font-family-sans);
44
+ }
45
+
46
+ @media (width >= 896px) {
47
+ .ion-header {
48
+ --ion-header-padding-x: var(--spacing-32);
49
+ }
50
+ }
51
+
52
+ @media (width >= 1216px) {
53
+ .ion-header {
54
+ /* 56 is off the spacing scale (it jumps 48 -> 64), so it is stated as
55
+ * arithmetic on two rungs rather than as a literal. */
56
+ --ion-header-height: calc(var(--spacing-64) - var(--spacing-8));
57
+ --ion-header-padding-y: var(--spacing-8);
58
+ --ion-header-padding-x: var(--spacing-64);
59
+ --ion-header-end-gap: var(--spacing-12);
60
+ }
61
+ }
62
+
63
+ .ion-header__brand {
64
+ display: flex;
65
+ align-items: center;
66
+ gap: var(--spacing-4);
67
+ flex-shrink: 0;
68
+ color: var(--icon-default);
69
+ }
70
+
71
+ /*
72
+ * The centre slot is hidden on Tablet and Mobile in Figma — both variants ship
73
+ * it collapsed to zero. It is display:none rather than visibility:hidden so it
74
+ * does not hold width the narrow layouts do not have.
75
+ */
76
+ .ion-header__center {
77
+ display: none;
78
+ align-items: center;
79
+ gap: var(--spacing-8);
80
+ flex: 1 1 auto;
81
+ min-width: 0;
82
+ }
83
+
84
+ @media (width >= 1216px) {
85
+ .ion-header__center {
86
+ display: flex;
87
+ }
88
+ }
89
+
90
+ .ion-header__end {
91
+ display: flex;
92
+ align-items: center;
93
+ gap: var(--ion-header-end-gap);
94
+ flex-shrink: 0;
95
+ }
@@ -0,0 +1,44 @@
1
+ @import url('https://fonts.googleapis.com/css2?family=Host+Grotesk:ital,wght@0,300..800;1,300..800&family=Merriweather:ital,wght@0,300..900;1,300..900&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&family=STIX+Two+Text:ital,wght@0,400..700;1,400..700&display=swap');
2
+ @import url('./tokens/index.css');
3
+ @import url('./button.css');
4
+ @import url('./tabs.css');
5
+ @import url('./badge.css');
6
+ @import url('./input.css');
7
+ @import url('./select.css');
8
+ @import url('./checkbox.css');
9
+ @import url('./radio.css');
10
+ @import url('./toggle.css');
11
+ @import url('./menu.css');
12
+ @import url('./avatar.css');
13
+ @import url('./header.css');
14
+ @import url('./logo.css');
15
+ @import url('./table.css');
16
+ @import url('./divider.css');
17
+ @import url('./nav-item.css');
18
+ @import url('./scroll-progress.css');
19
+
20
+ /*
21
+ * Every IonBase element is border-box.
22
+ *
23
+ * Not cosmetic: `<button>` inherits border-box from the UA stylesheet but
24
+ * `<div>` does not, so a component built on a div sized its min-height against
25
+ * the CONTENT box and came out 16px taller than the design. Button looked
26
+ * correct only by accident of its tag name.
27
+ *
28
+ * Scoped to `ion-` classes so the design system never restyles a host app.
29
+ */
30
+ [class^='ion-'],
31
+ [class*=' ion-'] {
32
+ box-sizing: border-box;
33
+ }
34
+
35
+ /* Global baseline resets using token variables only */
36
+ body {
37
+ font-family: var(--font-family-sans);
38
+ font-size: var(--type-body);
39
+ line-height: var(--type-body-line-height);
40
+ background-color: var(--surface-page);
41
+ color: var(--text-default);
42
+ -webkit-font-smoothing: antialiased;
43
+ -moz-osx-font-smoothing: grayscale;
44
+ }
@@ -0,0 +1,264 @@
1
+ /*
2
+ * Input
3
+ *
4
+ * Measured from Figma `Input` (80:275) and `Form Field` (80:330).
5
+ * Three sizes x seven states, plus the label/helper wrapper.
6
+ *
7
+ * GEOMETRY COMES FROM THE SPACING SCALE, NOT FROM A CONTROL RECIPE
8
+ *
9
+ * 1. Every measurement below is a rung on `spacing/*` (or the `icon-size`
10
+ * ladder), picked by this component. There is no shared "control" token
11
+ * prescribing what a medium field's padding should be — that group existed,
12
+ * was reverse-engineered from Button's numbers, and was bound by 3 of 26
13
+ * components because nothing whose measurements differed could use it.
14
+ *
15
+ * So Input picking 12 where Button picks 16 is not a divergence to explain
16
+ * away. It is a component fact, recorded here, which is the only place that
17
+ * knows it. The scale is the shared contract; the rung is not.
18
+ *
19
+ * 2. Figma draws its 1px stroke INSIDE the frame, so padding is measured from
20
+ * the outer edge and the stroke overlaps it. A CSS border sits outside the
21
+ * padding box, so `padding-x: 12px` would put the text at 13px. The border
22
+ * width is subtracted back out — the same correction Badge and Tabs needed.
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.
28
+ */
29
+ .ion-input {
30
+ /*
31
+ * Size is a set of slots defaulting to Medium, so a size modifier reassigns
32
+ * six variables rather than restating six declarations — same shape as
33
+ * Button, and it keeps the icon rule size-agnostic.
34
+ */
35
+ --ion-input-height: var(--spacing-40);
36
+ --ion-input-padding-x: var(--spacing-12);
37
+ --ion-input-gap: var(--spacing-8);
38
+ --ion-input-icon-size: var(--icon-size-md);
39
+ --ion-input-radius: var(--radius-md);
40
+ --ion-input-font-size: var(--type-body);
41
+ --ion-input-line-height: var(--type-body-line-height);
42
+ --ion-input-border-width: var(--border-width-default);
43
+
44
+ display: flex;
45
+ align-items: center;
46
+ gap: var(--ion-input-gap);
47
+
48
+ /*
49
+ * Figma pins each size to a fixed height (32/40/48) with no vertical padding.
50
+ * `min-height` reproduces that while still growing if the field is ever given
51
+ * a taller control inside it.
52
+ */
53
+ min-height: var(--ion-input-height);
54
+ padding-inline: calc(
55
+ var(--ion-input-padding-x) - var(--ion-input-border-width)
56
+ );
57
+ background-color: var(--surface-default);
58
+ border-style: solid;
59
+ border-width: var(--ion-input-border-width);
60
+ border-color: var(--border-default);
61
+ border-radius: var(--ion-input-radius);
62
+ font-family: var(--font-family-sans);
63
+ font-size: var(--ion-input-font-size);
64
+ line-height: var(--ion-input-line-height);
65
+ transition:
66
+ border-color 150ms cubic-bezier(0.4, 0, 0.2, 1),
67
+ background-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
68
+ }
69
+
70
+ /*
71
+ * Small shares Medium's padding. It was 10px and unbound in Figma — the only
72
+ * untokenised measurement in either component, and off a scale that runs
73
+ * 8, 12, 16, 20. Resolved in Figma by moving to 12; height, gap, radius and the
74
+ * type ramp still separate the two sizes.
75
+ */
76
+ .ion-input--sm {
77
+ --ion-input-height: var(--spacing-32);
78
+ --ion-input-padding-x: var(--spacing-12);
79
+ --ion-input-gap: var(--spacing-6);
80
+ --ion-input-icon-size: var(--icon-size-sm);
81
+ --ion-input-radius: var(--radius-sm);
82
+ --ion-input-font-size: var(--type-body-sm);
83
+ --ion-input-line-height: var(--type-body-sm-line-height);
84
+ }
85
+
86
+ /* Large keeps Medium's radius, icon size and type ramp — only height and padding grow. */
87
+ .ion-input--lg {
88
+ --ion-input-height: var(--spacing-48);
89
+ --ion-input-padding-x: var(--spacing-16);
90
+ }
91
+
92
+ /*
93
+ * The field itself carries no box of its own — the wrapper owns the border,
94
+ * background and padding so the icons sit inside it. Everything here is either
95
+ * inherited or reset.
96
+ */
97
+ .ion-input__field {
98
+ flex: 1 1 auto;
99
+ min-width: 0;
100
+ padding: 0;
101
+ background-color: transparent;
102
+ border: none;
103
+ outline: none;
104
+ color: var(--text-secondary);
105
+ font-family: inherit;
106
+ font-size: inherit;
107
+ line-height: inherit;
108
+ font-weight: var(--font-weight-regular);
109
+ }
110
+
111
+ /*
112
+ * Figma's Filled state differs from Default only in the value colour
113
+ * (text/tertiary -> text/secondary). That is exactly the placeholder
114
+ * distinction, so it needs no variant: the field is text/secondary and the
115
+ * placeholder pseudo-element is text/tertiary.
116
+ */
117
+ .ion-input__field::placeholder {
118
+ color: var(--text-tertiary);
119
+ opacity: 1;
120
+ }
121
+
122
+ .ion-input__icon-start,
123
+ .ion-input__icon-end {
124
+ display: inline-flex;
125
+ align-items: center;
126
+ justify-content: center;
127
+ flex-shrink: 0;
128
+ }
129
+
130
+ /* One rule for every size — the slot is reassigned by the size modifier. */
131
+ .ion-input__icon-start svg,
132
+ .ion-input__icon-end svg {
133
+ width: var(--ion-input-icon-size);
134
+ height: var(--ion-input-icon-size);
135
+ }
136
+
137
+ /*
138
+ * The two icons carry different tokens. The leading icon is decorative
139
+ * (icon/tertiary); the trailing one is a control — clear, reveal, calendar —
140
+ * and sits at icon/interactive. v2 splits text and icon precisely so this can
141
+ * differ from the value colour beside it.
142
+ */
143
+ .ion-input__icon-start {
144
+ color: var(--icon-tertiary);
145
+ }
146
+
147
+ .ion-input__icon-end {
148
+ color: var(--icon-interactive);
149
+ }
150
+
151
+ /*
152
+ * State order below is deliberate and equal-specificity, so later rules win:
153
+ * hover -> focus -> invalid -> read-only -> disabled.
154
+ *
155
+ * Invalid sits after focus and overrides the colour only, never the width. A
156
+ * focused invalid field therefore keeps the 2px ring but shows it in the error
157
+ * colour, which is both states being true at once rather than one masking the
158
+ * other.
159
+ */
160
+
161
+ /*
162
+ * Hover is declared twice, as it is on Button. Native `:hover` latches on touch
163
+ * until you tap elsewhere; `[data-hovered]` comes from React Aria's useHover,
164
+ * which is pointer-aware. The media query keeps this working without React.
165
+ */
166
+ @media (hover: hover) {
167
+ .ion-input:hover {
168
+ border-color: var(--border-strong);
169
+ }
170
+ }
171
+
172
+ .ion-input[data-hovered='true'] {
173
+ border-color: var(--border-strong);
174
+ }
175
+
176
+ .ion-input:focus-within,
177
+ .ion-input[data-focused='true'] {
178
+ --ion-input-border-width: var(--border-width-thick);
179
+
180
+ border-color: var(--border-focus);
181
+ }
182
+
183
+ /*
184
+ * 2px, matching Focus and matching Select. At 1px the invalid state differed
185
+ * from default only in hue, which is the exact failure mode WCAG 1.4.1 is
186
+ * about — error has to be perceivable without relying on colour alone.
187
+ * Reconciled in Figma; Input was the one that was wrong.
188
+ */
189
+ .ion-input--invalid,
190
+ .ion-input[data-invalid='true'] {
191
+ --ion-input-border-width: var(--border-width-thick);
192
+
193
+ border-color: var(--border-error-strong);
194
+ }
195
+
196
+ /* Read-only keeps the value legible but drops the field out of the editable
197
+ * palette entirely — page surface, subtle border. */
198
+ .ion-input--readonly,
199
+ .ion-input[data-readonly='true'] {
200
+ background-color: var(--surface-page);
201
+ border-color: var(--border-subtle);
202
+ }
203
+
204
+ .ion-input--disabled,
205
+ .ion-input[data-disabled='true'] {
206
+ background-color: var(--surface-disabled);
207
+ border-color: var(--border-disabled);
208
+ cursor: not-allowed;
209
+ }
210
+
211
+ .ion-input--disabled .ion-input__field,
212
+ .ion-input[data-disabled='true'] .ion-input__field {
213
+ color: var(--text-disabled);
214
+ cursor: not-allowed;
215
+ }
216
+
217
+ .ion-input--disabled .ion-input__field::placeholder,
218
+ .ion-input[data-disabled='true'] .ion-input__field::placeholder {
219
+ color: var(--text-disabled);
220
+ }
221
+
222
+ .ion-input--disabled .ion-input__icon-start,
223
+ .ion-input--disabled .ion-input__icon-end,
224
+ .ion-input[data-disabled='true'] .ion-input__icon-start,
225
+ .ion-input[data-disabled='true'] .ion-input__icon-end {
226
+ color: var(--icon-disabled);
227
+ }
228
+
229
+ /*
230
+ * Form Field — Figma `Form Field` (80:330).
231
+ *
232
+ * Label + control + helper stacked at 6px. Named `ion-field` rather than
233
+ * `ion-input__field-wrapper` because Select uses it unchanged: the wrapper
234
+ * knows nothing about what control sits in the middle.
235
+ *
236
+ * Only the Error variant differs from Default. Figma's Disabled variant leaves
237
+ * both the label and the helper on their default tokens, so it needs no rule —
238
+ * the control inside carries the whole disabled treatment.
239
+ */
240
+ .ion-field {
241
+ display: flex;
242
+ flex-direction: column;
243
+ gap: var(--spacing-6);
244
+ }
245
+
246
+ .ion-field__label {
247
+ color: var(--text-secondary);
248
+ font-family: var(--font-family-sans);
249
+ font-size: var(--type-body-sm);
250
+ line-height: var(--type-body-sm-line-height);
251
+ font-weight: var(--font-weight-medium);
252
+ }
253
+
254
+ .ion-field__helper {
255
+ color: var(--text-tertiary);
256
+ font-family: var(--font-family-sans);
257
+ font-size: var(--type-body-sm);
258
+ line-height: var(--type-body-sm-line-height);
259
+ font-weight: var(--font-weight-regular);
260
+ }
261
+
262
+ .ion-field--error .ion-field__helper {
263
+ color: var(--text-error);
264
+ }