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,63 @@
1
+ /*
2
+ * Logo
3
+ *
4
+ * Measured from Figma `Logo-Ionbase` (52:21369) — Small (24px mark) and Large
5
+ * (32px), each with two wordmark treatments.
6
+ *
7
+ * TWO WORDMARK KINDS, TWO DIFFERENT TYPEFACES, TWO DIFFERENT TOKENS
8
+ *
9
+ * Figma's `Type=Logo` draws the wordmark as outlined vector paths in a serif
10
+ * logotype — frozen brand artwork, bound to `icon/default`, same colour as
11
+ * the mark. `Type=Icon` draws it as live text in sans-serif Host Grotesk,
12
+ * bound to `text/tertiary` — one step lighter, the same text/icon split v2
13
+ * uses everywhere else. These are not the same word in two technologies; they
14
+ * are two different marks for two different contexts, confirmed by rendering
15
+ * both rather than assumed from Figma's node names. Both colours are kept
16
+ * distinct rather than unified under one `currentColor` flowing from the
17
+ * wrapper, because the tokens genuinely differ.
18
+ *
19
+ * ONE VALUE LEFT AS FIGMA DREW IT
20
+ *
21
+ * The Large text wordmark is unbound in Figma — 21.333px over a 32px
22
+ * line-height, exactly Small's 16/24 scaled by 4/3. Not on the font-size
23
+ * ladder, and not rounded here: rounding would make the live-text lockup a
24
+ * different size than the vector one sitting right next to it in Figma.
25
+ */
26
+ .ion-logo {
27
+ display: inline-flex;
28
+ align-items: center;
29
+ gap: var(--spacing-4);
30
+ }
31
+
32
+ .ion-logo--lg {
33
+ gap: var(--spacing-8);
34
+ }
35
+
36
+ .ion-logo__mark {
37
+ display: block;
38
+ flex-shrink: 0;
39
+ color: var(--icon-default);
40
+ }
41
+
42
+ .ion-logo__wordmark--vector {
43
+ display: block;
44
+ flex-shrink: 0;
45
+ color: var(--icon-default);
46
+ }
47
+
48
+ .ion-logo__wordmark--text {
49
+ color: var(--text-tertiary);
50
+ font-family: var(--font-family-sans);
51
+ font-weight: var(--font-weight-medium);
52
+ font-size: var(--type-body);
53
+ line-height: var(--type-body-line-height);
54
+ white-space: nowrap;
55
+ }
56
+
57
+ .ion-logo--lg .ion-logo__wordmark--text {
58
+ /* Figma's own unbound 4/3 scale of 16/24 — see the header. Written as
59
+ * arithmetic on type/body so the value stays a token expression, not a
60
+ * literal, even though the relationship itself is Figma's, not derived. */
61
+ font-size: calc(var(--type-body) * 4 / 3);
62
+ line-height: calc(var(--type-body-line-height) * 4 / 3);
63
+ }
@@ -0,0 +1,150 @@
1
+ /*
2
+ * Menu
3
+ *
4
+ * Measured from Figma `Menu` (85:22440) and `Menu Item`.
5
+ *
6
+ * menu padding 6, gap 2, radius/xl, surface/default, 1px border/subtle
7
+ * item 40 tall, padding 8/12, gap 8, radius/sm
8
+ *
9
+ * Figma models the list only — there is no trigger, popover or positioning in
10
+ * the design, so there is none here. This is the surface a menu renders on;
11
+ * anchoring it is the caller's job until Figma has a component that says
12
+ * otherwise.
13
+ *
14
+ * SELECTED-HOVER IS NOT SELECTED PLUS HOVER
15
+ *
16
+ * Figma's `Selected-hover` drops back to `surface/hover` and recolours the
17
+ * label to text/secondary — only the trailing check stays icon/primary. So
18
+ * hovering a selected row loses the primary tint rather than deepening it,
19
+ * which is the opposite of what composing the two states would produce. That is
20
+ * why hover is not a modifier layered on top of selected here.
21
+ */
22
+ .ion-menu {
23
+ display: flex;
24
+ flex-direction: column;
25
+ gap: var(--spacing-2);
26
+ padding: var(--spacing-6);
27
+ background-color: var(--surface-default);
28
+ border-style: solid;
29
+ border-width: var(--border-width-default);
30
+ border-color: var(--border-subtle);
31
+ border-radius: var(--radius-xl);
32
+ font-family: var(--font-family-sans);
33
+ list-style: none;
34
+ margin: 0;
35
+ }
36
+
37
+ .ion-menu__item {
38
+ display: flex;
39
+ align-items: center;
40
+ gap: var(--spacing-8);
41
+ min-height: var(--spacing-40);
42
+
43
+ /* No border on the item, so no border correction — unlike Input or Toggle. */
44
+ padding: var(--spacing-8) var(--spacing-12);
45
+ background-color: transparent;
46
+ border: none;
47
+ border-radius: var(--radius-sm);
48
+ color: var(--text-secondary);
49
+ font-family: inherit;
50
+ font-size: var(--type-body);
51
+ line-height: var(--type-body-line-height);
52
+ font-weight: var(--font-weight-regular);
53
+ text-align: left;
54
+ width: 100%;
55
+ cursor: pointer;
56
+ user-select: none;
57
+ transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
58
+ }
59
+
60
+ .ion-menu__icon,
61
+ .ion-menu__check {
62
+ display: inline-flex;
63
+ align-items: center;
64
+ justify-content: center;
65
+ flex-shrink: 0;
66
+ color: var(--icon-default);
67
+ }
68
+
69
+ .ion-menu__icon svg,
70
+ .ion-menu__check svg {
71
+ width: var(--icon-size-sm);
72
+ height: var(--icon-size-sm);
73
+ }
74
+
75
+ /* The check keeps its slot whether or not it is showing, so rows do not
76
+ * reflow as the selection moves down the list. */
77
+ .ion-menu__check {
78
+ margin-left: auto;
79
+ visibility: hidden;
80
+ }
81
+
82
+ .ion-menu__label {
83
+ flex: 1 1 auto;
84
+ min-width: 0;
85
+ }
86
+
87
+ @media (hover: hover) {
88
+ .ion-menu__item:hover {
89
+ background-color: var(--surface-hover);
90
+ }
91
+ }
92
+
93
+ .ion-menu__item[data-hovered='true'] {
94
+ background-color: var(--surface-hover);
95
+ }
96
+
97
+ .ion-menu__item--selected {
98
+ background-color: var(--surface-primary-subtle);
99
+ color: var(--text-primary);
100
+ }
101
+
102
+ .ion-menu__item--selected .ion-menu__icon,
103
+ .ion-menu__item--selected .ion-menu__check {
104
+ color: var(--icon-primary);
105
+ }
106
+
107
+ .ion-menu__item--selected .ion-menu__check {
108
+ visibility: visible;
109
+ }
110
+
111
+ /* See the header — hovering a selected row drops the tint rather than deepening
112
+ * it. The check stays primary; everything else returns to the resting palette. */
113
+ @media (hover: hover) {
114
+ .ion-menu__item--selected:hover {
115
+ background-color: var(--surface-hover);
116
+ color: var(--text-secondary);
117
+ }
118
+
119
+ .ion-menu__item--selected:hover .ion-menu__icon {
120
+ color: var(--icon-default);
121
+ }
122
+ }
123
+
124
+ .ion-menu__item--selected[data-hovered='true'] {
125
+ background-color: var(--surface-hover);
126
+ color: var(--text-secondary);
127
+ }
128
+
129
+ .ion-menu__item--selected[data-hovered='true'] .ion-menu__icon {
130
+ color: var(--icon-default);
131
+ }
132
+
133
+ .ion-menu__item:focus-visible {
134
+ outline: var(--border-width-thick) solid var(--border-focus);
135
+ outline-offset: calc(-1 * var(--border-width-thick));
136
+ }
137
+
138
+ .ion-menu__item:disabled,
139
+ .ion-menu__item[data-disabled='true'] {
140
+ background-color: transparent;
141
+ color: var(--text-disabled);
142
+ cursor: not-allowed;
143
+ }
144
+
145
+ .ion-menu__item:disabled .ion-menu__icon,
146
+ .ion-menu__item:disabled .ion-menu__check,
147
+ .ion-menu__item[data-disabled='true'] .ion-menu__icon,
148
+ .ion-menu__item[data-disabled='true'] .ion-menu__check {
149
+ color: var(--icon-disabled);
150
+ }
@@ -0,0 +1,120 @@
1
+ /*
2
+ * Nav Item
3
+ *
4
+ * Measured from Figma `Nav Item` (70:22078). Two states only: Default, Hover.
5
+ *
6
+ * NO BACKGROUND ON HOVER — TEXT AND ICON RECOLOUR ONLY
7
+ *
8
+ * Unlike every other interactive surface in this system (Button, Menu Item,
9
+ * Table Row all paint `surface/hover` underneath), Nav Item's hover is
10
+ * `text/interactive/hover` and `icon/interactive/hover` and nothing else — no
11
+ * fill, no border. Reproduced as measured, not "fixed" to match the others:
12
+ * a primary nav bar sitting directly on the header's own surface has nothing
13
+ * to contrast a hover fill against without inventing a colour Figma never
14
+ * specified.
15
+ *
16
+ * A FOCUS RING FIGMA DOESN'T SHOW, ADDED ANYWAY
17
+ *
18
+ * Figma has no third state for keyboard focus. Every other interactive
19
+ * component in the system has one, so the same outline treatment is added
20
+ * here for the same reason Input's disabled state was — an interaction a
21
+ * real browser can reach that the static variant set doesn't enumerate isn't
22
+ * optional just because Figma has no swatch for it.
23
+ */
24
+ .ion-nav-item {
25
+ display: inline-flex;
26
+ align-items: center;
27
+ gap: var(--spacing-4);
28
+ padding: var(--spacing-8);
29
+ background-color: transparent;
30
+ border: none;
31
+ border-radius: var(--radius-sm);
32
+ color: var(--text-secondary);
33
+ font-family: var(--font-family-sans);
34
+ font-size: var(--type-body-sm);
35
+ line-height: var(--type-body-sm-line-height);
36
+ font-weight: var(--font-weight-regular);
37
+ text-decoration: none;
38
+ cursor: pointer;
39
+ user-select: none;
40
+ transition: color 150ms cubic-bezier(0.4, 0, 0.2, 1);
41
+ }
42
+
43
+ .ion-nav-item__icon,
44
+ .ion-nav-item__chevron {
45
+ display: inline-flex;
46
+ align-items: center;
47
+ justify-content: center;
48
+ flex-shrink: 0;
49
+ color: var(--icon-tertiary);
50
+ transition: color 150ms cubic-bezier(0.4, 0, 0.2, 1);
51
+ }
52
+
53
+ .ion-nav-item__icon svg,
54
+ .ion-nav-item__chevron svg {
55
+ width: var(--icon-size-sm);
56
+ height: var(--icon-size-sm);
57
+ }
58
+
59
+ .ion-nav-item:focus-visible,
60
+ .ion-nav-item[data-focused='true'] {
61
+ outline: var(--border-width-thick) solid var(--border-focus);
62
+ outline-offset: 2px;
63
+ }
64
+
65
+ .ion-nav-item--disabled,
66
+ .ion-nav-item[data-disabled='true'] {
67
+ color: var(--text-disabled);
68
+ cursor: not-allowed;
69
+ }
70
+
71
+ .ion-nav-item--disabled .ion-nav-item__icon,
72
+ .ion-nav-item--disabled .ion-nav-item__chevron,
73
+ .ion-nav-item[data-disabled='true'] .ion-nav-item__icon,
74
+ .ion-nav-item[data-disabled='true'] .ion-nav-item__chevron {
75
+ color: var(--icon-disabled);
76
+ }
77
+
78
+ /*
79
+ * Every hover selector excludes disabled with one `:not()` argument list,
80
+ * rather than relying on disabled simply appearing earlier in the file. Both
81
+ * states target the same nested icon/chevron element at equal specificity
82
+ * (one qualifier + one descendant), so without the exclusion, source order
83
+ * alone would decide the winner — fragile, and the same shape of bug
84
+ * Checkbox's disabled/checked conflict already was. Excluding disabled from
85
+ * hover directly means the two can never collide regardless of file order;
86
+ * disabled is written above only because it is the simpler selector and
87
+ * stylelint wants specificity to read non-decreasing top to bottom.
88
+ */
89
+ @media (hover: hover) {
90
+ .ion-nav-item:hover:not(.ion-nav-item--disabled, [data-disabled='true']) {
91
+ color: var(--text-interactive-hover);
92
+ }
93
+
94
+ .ion-nav-item:hover:not(.ion-nav-item--disabled, [data-disabled='true'])
95
+ .ion-nav-item__icon,
96
+ .ion-nav-item:hover:not(.ion-nav-item--disabled, [data-disabled='true'])
97
+ .ion-nav-item__chevron {
98
+ color: var(--icon-interactive-hover);
99
+ }
100
+ }
101
+
102
+ .ion-nav-item[data-hovered='true']:not(
103
+ .ion-nav-item--disabled,
104
+ [data-disabled='true']
105
+ ) {
106
+ color: var(--text-interactive-hover);
107
+ }
108
+
109
+ .ion-nav-item[data-hovered='true']:not(
110
+ .ion-nav-item--disabled,
111
+ [data-disabled='true']
112
+ )
113
+ .ion-nav-item__icon,
114
+ .ion-nav-item[data-hovered='true']:not(
115
+ .ion-nav-item--disabled,
116
+ [data-disabled='true']
117
+ )
118
+ .ion-nav-item__chevron {
119
+ color: var(--icon-interactive-hover);
120
+ }
@@ -0,0 +1,200 @@
1
+ /*
2
+ * Radio
3
+ *
4
+ * Measured from Figma `Radio` (18 variants: 3 colours x 3 sizes x 2 states).
5
+ *
6
+ * Same construction as Checkbox — hidden native input, styled sibling, outline
7
+ * for the focus ring — with three differences Figma is explicit about:
8
+ *
9
+ * - the indicator is a circle (radius/full), not a rounded square
10
+ * - the selected mark is a filled dot, not a glyph, so it scales with the box
11
+ * - there is no disabled variant in the design; disabled is handled here
12
+ * anyway, because a form can always disable a field and the component must
13
+ * not fall back to looking enabled
14
+ *
15
+ * THE DOT IS DERIVED
16
+ *
17
+ * The dot is 6 / 8 / 10 against indicators of 16 / 20 / 24. 10 is on no ladder —
18
+ * spacing jumps 8 to 12 — but it follows from the box:
19
+ *
20
+ * dot = box / 2 - 2 16->6 20->8 24->10
21
+ *
22
+ * So nothing here reaches for the raw `scale/*` ramp, and the dot stays in
23
+ * proportion if a size is ever retuned.
24
+ */
25
+ .ion-radio {
26
+ --ion-radio-size: var(--spacing-20);
27
+ --ion-radio-gap: var(--spacing-8);
28
+ --ion-radio-font-size: var(--type-body);
29
+ --ion-radio-line-height: var(--type-body-line-height);
30
+ --ion-radio-fill: var(--surface-primary);
31
+ --ion-radio-border: var(--border-primary-strong);
32
+
33
+ /* Bevelled when selected — flush, size-indexed. Same rungs as Checkbox. */
34
+ --ion-radio-raised: var(--ion-shadow-raised-flush-sm);
35
+
36
+ /* Derived — see the header. */
37
+ --ion-radio-dot: calc(var(--ion-radio-size) / 2 - var(--spacing-2));
38
+
39
+ display: inline-flex;
40
+ align-items: flex-start;
41
+ gap: var(--ion-radio-gap);
42
+ font-family: var(--font-family-sans);
43
+ font-size: var(--ion-radio-font-size);
44
+ line-height: var(--ion-radio-line-height);
45
+ cursor: pointer;
46
+ }
47
+
48
+ .ion-radio--sm {
49
+ --ion-radio-size: var(--spacing-16);
50
+ --ion-radio-font-size: var(--type-body-sm);
51
+ --ion-radio-line-height: var(--type-body-sm-line-height);
52
+ --ion-radio-raised: var(--ion-shadow-raised-flush-xs);
53
+ }
54
+
55
+ .ion-radio--lg {
56
+ --ion-radio-size: var(--spacing-24);
57
+ --ion-radio-gap: var(--spacing-12);
58
+ --ion-radio-raised: var(--ion-shadow-raised-flush-lg);
59
+ }
60
+
61
+ .ion-radio__input {
62
+ position: absolute;
63
+ width: 1px;
64
+ height: 1px;
65
+ margin: 0;
66
+ padding: 0;
67
+ border: none;
68
+ opacity: 0;
69
+ pointer-events: none;
70
+ }
71
+
72
+ .ion-radio__indicator {
73
+ display: inline-flex;
74
+ align-items: center;
75
+ justify-content: center;
76
+ flex-shrink: 0;
77
+ width: var(--ion-radio-size);
78
+ height: var(--ion-radio-size);
79
+ background-color: var(--surface-default);
80
+ border-style: solid;
81
+
82
+ /*
83
+ * The EMPTY circle carries the heavier border: 2px `border/stronger`, where
84
+ * the selected one drops to 1px of its intent colour. Same reasoning as
85
+ * Checkbox — an unselected control is nothing but its outline, so it has to
86
+ * hold the row on its own; once filled, the fill does that work.
87
+ *
88
+ * Figma strokes this INSIDE, and the global `box-sizing: border-box` in
89
+ * index.css is what reproduces it: the circle stays 16/20/24 and the border
90
+ * eats inward instead of growing the control.
91
+ */
92
+ border-width: var(--border-width-thick);
93
+ border-color: var(--border-stronger);
94
+ border-radius: var(--radius-full);
95
+
96
+ /* Centred on the label's first line — see checkbox.css. */
97
+ margin-top: calc((var(--ion-radio-line-height) - var(--ion-radio-size)) / 2);
98
+ transition:
99
+ background-color 150ms cubic-bezier(0.4, 0, 0.2, 1),
100
+ border-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
101
+ }
102
+
103
+ .ion-radio__dot {
104
+ width: var(--ion-radio-dot);
105
+ height: var(--ion-radio-dot);
106
+ background-color: var(--icon-on-color);
107
+ border-radius: var(--radius-full);
108
+
109
+ /* Hidden rather than unmounted so selecting never reflows the row. */
110
+ opacity: 0;
111
+ }
112
+
113
+ .ion-radio__label {
114
+ color: var(--text-secondary);
115
+ user-select: none;
116
+ }
117
+
118
+ .ion-radio__input:checked + .ion-radio__indicator {
119
+ /* Back to 1px — see the indicator's border comment. */
120
+ border-width: var(--border-width-default);
121
+ background-color: var(--ion-radio-fill);
122
+ border-color: var(--ion-radio-border);
123
+
124
+ /* Only the selected dot is bevelled; an empty circle stays flat. */
125
+ box-shadow: var(--ion-radio-raised);
126
+ }
127
+
128
+ .ion-radio__input:checked + .ion-radio__indicator .ion-radio__dot {
129
+ opacity: 1;
130
+ }
131
+
132
+ .ion-radio--neutral {
133
+ --ion-radio-fill: var(--surface-inverse);
134
+ --ion-radio-border: var(--border-inverse);
135
+ }
136
+
137
+ .ion-radio--danger {
138
+ --ion-radio-fill: var(--surface-error);
139
+ --ion-radio-border: var(--border-error-strong);
140
+ }
141
+
142
+ .ion-radio__input:focus-visible + .ion-radio__indicator,
143
+ .ion-radio[data-focused='true'] .ion-radio__indicator {
144
+ outline: var(--border-width-thick) solid var(--border-focus);
145
+ outline-offset: 0;
146
+ }
147
+
148
+ /* Driven off `:disabled` so it out-specifies `:checked` — see checkbox.css. */
149
+ .ion-radio__input:disabled + .ion-radio__indicator,
150
+ .ion-radio__input:checked:disabled + .ion-radio__indicator {
151
+ background-color: var(--surface-disabled);
152
+ border-color: var(--border-disabled);
153
+
154
+ /*
155
+ * NOT MEASURED — mirrored from Checkbox. Radio's variant set has only
156
+ * Unselected and Selected, so Figma says nothing about a disabled border
157
+ * width. Checkbox does have the variants and keeps 2px in all three disabled
158
+ * states, so Radio follows it rather than inheriting 1px from `:checked` by
159
+ * accident of specificity. Restated here for the same reason Checkbox has to:
160
+ * `:checked` sets 1px, and only the compound `:checked:disabled` beats it.
161
+ */
162
+ border-width: var(--border-width-thick);
163
+
164
+ /* Flat whether or not it is selected. */
165
+ box-shadow: none;
166
+ }
167
+
168
+ .ion-radio__input:disabled + .ion-radio__indicator .ion-radio__dot {
169
+ background-color: var(--icon-disabled);
170
+ }
171
+
172
+ .ion-radio--disabled,
173
+ .ion-radio[data-disabled='true'] {
174
+ cursor: not-allowed;
175
+ }
176
+
177
+ .ion-radio--disabled .ion-radio__label,
178
+ .ion-radio[data-disabled='true'] .ion-radio__label {
179
+ color: var(--text-disabled);
180
+ }
181
+
182
+ /* The group stacks its radios; Figma has no group component, so this is the
183
+ * minimum a fieldset needs rather than an invented layout. */
184
+ .ion-radio-group {
185
+ display: flex;
186
+ flex-direction: column;
187
+ gap: var(--spacing-8);
188
+ border: none;
189
+ margin: 0;
190
+ padding: 0;
191
+ }
192
+
193
+ .ion-radio-group__label {
194
+ color: var(--text-secondary);
195
+ font-family: var(--font-family-sans);
196
+ font-size: var(--type-body-sm);
197
+ line-height: var(--type-body-sm-line-height);
198
+ font-weight: var(--font-weight-medium);
199
+ padding: 0;
200
+ }
@@ -0,0 +1,171 @@
1
+ /*
2
+ * Scroll Progress
3
+ *
4
+ * Measured from Figma `Line` / `Progress` / `Progress Heading` (191:927).
5
+ *
6
+ * FIGMA SPECIFIES STATES, NOT SCROLL-TRACKING
7
+ *
8
+ * The "32%" and the one heavier tick in Figma's mockup are just that variant's
9
+ * static content — evidence of what the rail looks like at some scroll
10
+ * position, not a scroll-tracking algorithm to reproduce. This component
11
+ * takes `progress` and `activeId` as props for the same reason Select takes
12
+ * `value` rather than reading a DOM scroll position itself: which container
13
+ * scrolls, whether the page is one document or many, and how "the active
14
+ * section" is defined are all facts only the consuming app has.
15
+ *
16
+ * TICKS ARE 2px, NOT 1px — MEASURED, NOT A TYPO
17
+ *
18
+ * Figma's `Line` stroke is 2, `border-width/thick`, not `/default`. Easy to
19
+ * misread as a mistake since every hairline elsewhere in the system is 1px;
20
+ * checked against the node rather than assumed.
21
+ *
22
+ * THE FLYOUT IS A DISCLOSURE, NOT A MENU
23
+ *
24
+ * No `role="menu"` here, for the same reason `Menu` itself doesn't claim one:
25
+ * that role promises roving-tabindex arrow-key navigation and typeahead, and
26
+ * this component doesn't implement either — its rows sit in normal tab order.
27
+ * The trigger/panel pair follows the WAI-ARIA Disclosure pattern instead:
28
+ * `aria-expanded` + `aria-controls`, nothing more elaborate.
29
+ *
30
+ * REVEAL TRANSITION IS DELIBERATELY PLAIN
31
+ *
32
+ * Opacity and an 8px slide, the same 150ms/cubic-bezier(0.4,0,0.2,1) language
33
+ * every hover state in the system already uses. The deeper choreography this
34
+ * component could eventually justify — a staggered reveal per row, easing
35
+ * that matches the rail's own scale — is future work, tracked in
36
+ * docs/motion-system.md, not invented here ahead of that decision.
37
+ */
38
+ .ion-scroll-progress {
39
+ position: relative;
40
+ display: inline-flex;
41
+ }
42
+
43
+ .ion-scroll-progress__trigger {
44
+ display: flex;
45
+ flex-direction: column;
46
+ align-items: center;
47
+ gap: var(--spacing-8);
48
+ padding: 0;
49
+ background: none;
50
+ border: none;
51
+ border-radius: var(--radius-sm);
52
+ cursor: pointer;
53
+ }
54
+
55
+ .ion-scroll-progress__trigger:focus-visible {
56
+ outline: var(--border-width-thick) solid var(--border-focus);
57
+ outline-offset: 2px;
58
+ }
59
+
60
+ .ion-scroll-progress__percent {
61
+ color: var(--text-tertiary);
62
+ font-family: var(--font-family-sans);
63
+ font-size: var(--type-body-sm);
64
+ line-height: var(--type-body-sm-line-height);
65
+ transition: color 150ms cubic-bezier(0.4, 0, 0.2, 1);
66
+ }
67
+
68
+ .ion-scroll-progress__rail {
69
+ display: flex;
70
+ flex-direction: column;
71
+ align-items: center;
72
+ gap: var(--spacing-12);
73
+ }
74
+
75
+ .ion-scroll-progress__tick {
76
+ width: var(--spacing-32);
77
+ height: var(--border-width-thick);
78
+ background-color: var(--border-strong);
79
+ transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
80
+ }
81
+
82
+ .ion-scroll-progress__tick--active {
83
+ background-color: var(--border-stronger);
84
+ }
85
+
86
+ @media (hover: hover) {
87
+ .ion-scroll-progress__trigger:hover .ion-scroll-progress__percent {
88
+ color: var(--text-secondary);
89
+ }
90
+ }
91
+
92
+ .ion-scroll-progress__trigger[data-hovered='true']
93
+ .ion-scroll-progress__percent {
94
+ color: var(--text-secondary);
95
+ }
96
+
97
+ /*
98
+ * Hidden via visibility + opacity, not `display: none` — the transition needs
99
+ * a property to animate, and `visibility` still pulls the panel's buttons out
100
+ * of tab order and the accessibility tree while closed, same as a native
101
+ * `<details>` collapses its content.
102
+ */
103
+ .ion-scroll-progress__panel {
104
+ position: absolute;
105
+ left: 0;
106
+ top: 50%;
107
+ z-index: 10;
108
+ display: flex;
109
+ flex-direction: column;
110
+ gap: var(--spacing-12);
111
+ min-width: 220px;
112
+ padding: var(--spacing-8);
113
+ background-color: var(--surface-default);
114
+ border: var(--border-width-default) solid var(--border-default);
115
+ border-radius: var(--radius-md);
116
+ opacity: 0;
117
+ visibility: hidden;
118
+ pointer-events: none;
119
+ transform: translateY(-50%) translateX(-4px);
120
+ transition:
121
+ opacity 150ms cubic-bezier(0.4, 0, 0.2, 1),
122
+ transform 150ms cubic-bezier(0.4, 0, 0.2, 1),
123
+ visibility 150ms;
124
+ }
125
+
126
+ .ion-scroll-progress__panel[data-open='true'] {
127
+ opacity: 1;
128
+ visibility: visible;
129
+ pointer-events: auto;
130
+ transform: translateY(-50%) translateX(0);
131
+ }
132
+
133
+ .ion-scroll-progress__heading {
134
+ display: flex;
135
+ align-items: center;
136
+ padding: var(--spacing-2) var(--spacing-8);
137
+ background-color: transparent;
138
+ border: var(--border-width-default) solid transparent;
139
+ border-radius: var(--radius-md);
140
+ color: var(--text-secondary);
141
+ font-family: var(--font-family-sans);
142
+ font-size: var(--type-body-sm);
143
+ line-height: var(--type-body-sm-line-height);
144
+ font-weight: var(--font-weight-regular);
145
+ text-align: left;
146
+ cursor: pointer;
147
+ transition:
148
+ background-color 150ms cubic-bezier(0.4, 0, 0.2, 1),
149
+ border-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
150
+ }
151
+
152
+ @media (hover: hover) {
153
+ .ion-scroll-progress__heading:hover {
154
+ background-color: var(--surface-muted);
155
+ border-color: var(--border-strong);
156
+ }
157
+ }
158
+
159
+ .ion-scroll-progress__heading[data-hovered='true'] {
160
+ background-color: var(--surface-muted);
161
+ border-color: var(--border-strong);
162
+ }
163
+
164
+ .ion-scroll-progress__heading--selected {
165
+ background-color: var(--surface-muted);
166
+ }
167
+
168
+ .ion-scroll-progress__heading:focus-visible {
169
+ outline: var(--border-width-thick) solid var(--border-focus);
170
+ outline-offset: -1px;
171
+ }