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.
- package/LICENSE +21 -0
- package/README.md +74 -0
- package/dist/Icon.d.ts +72 -0
- package/dist/Icon.d.ts.map +1 -0
- package/dist/Icon.js +50 -0
- package/dist/Icon.js.map +1 -0
- package/dist/components/Avatar.d.ts +52 -0
- package/dist/components/Avatar.d.ts.map +1 -0
- package/dist/components/Avatar.js +59 -0
- package/dist/components/Avatar.js.map +1 -0
- package/dist/components/Badge.d.ts +26 -0
- package/dist/components/Badge.d.ts.map +1 -0
- package/dist/components/Badge.js +17 -0
- package/dist/components/Badge.js.map +1 -0
- package/dist/components/Button.d.ts +18 -0
- package/dist/components/Button.d.ts.map +1 -0
- package/dist/components/Button.js +43 -0
- package/dist/components/Button.js.map +1 -0
- package/dist/components/Checkbox.d.ts +30 -0
- package/dist/components/Checkbox.d.ts.map +1 -0
- package/dist/components/Checkbox.js +39 -0
- package/dist/components/Checkbox.js.map +1 -0
- package/dist/components/Divider.d.ts +17 -0
- package/dist/components/Divider.d.ts.map +1 -0
- package/dist/components/Divider.js +16 -0
- package/dist/components/Divider.js.map +1 -0
- package/dist/components/Header.d.ts +25 -0
- package/dist/components/Header.d.ts.map +1 -0
- package/dist/components/Header.js +18 -0
- package/dist/components/Header.js.map +1 -0
- package/dist/components/Input.d.ts +32 -0
- package/dist/components/Input.d.ts.map +1 -0
- package/dist/components/Input.js +66 -0
- package/dist/components/Input.js.map +1 -0
- package/dist/components/Logo.d.ts +41 -0
- package/dist/components/Logo.d.ts.map +1 -0
- package/dist/components/Logo.js +70 -0
- package/dist/components/Logo.js.map +1 -0
- package/dist/components/Menu.d.ts +29 -0
- package/dist/components/Menu.d.ts.map +1 -0
- package/dist/components/Menu.js +31 -0
- package/dist/components/Menu.js.map +1 -0
- package/dist/components/NavItem.d.ts +30 -0
- package/dist/components/NavItem.d.ts.map +1 -0
- package/dist/components/NavItem.js +45 -0
- package/dist/components/NavItem.js.map +1 -0
- package/dist/components/Radio.d.ts +30 -0
- package/dist/components/Radio.d.ts.map +1 -0
- package/dist/components/Radio.js +61 -0
- package/dist/components/Radio.js.map +1 -0
- package/dist/components/ScrollProgress.d.ts +33 -0
- package/dist/components/ScrollProgress.d.ts.map +1 -0
- package/dist/components/ScrollProgress.js +115 -0
- package/dist/components/ScrollProgress.js.map +1 -0
- package/dist/components/Select.d.ts +43 -0
- package/dist/components/Select.d.ts.map +1 -0
- package/dist/components/Select.js +80 -0
- package/dist/components/Select.js.map +1 -0
- package/dist/components/Table.d.ts +71 -0
- package/dist/components/Table.d.ts.map +1 -0
- package/dist/components/Table.js +60 -0
- package/dist/components/Table.js.map +1 -0
- package/dist/components/Tabs.d.ts +23 -0
- package/dist/components/Tabs.d.ts.map +1 -0
- package/dist/components/Tabs.js +47 -0
- package/dist/components/Tabs.js.map +1 -0
- package/dist/components/Toggle.d.ts +25 -0
- package/dist/components/Toggle.d.ts.map +1 -0
- package/dist/components/Toggle.js +32 -0
- package/dist/components/Toggle.js.map +1 -0
- package/dist/components/index.d.ts +33 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +17 -0
- package/dist/components/index.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/styles/avatar.css +138 -0
- package/dist/styles/badge.css +125 -0
- package/dist/styles/button.css +331 -0
- package/dist/styles/checkbox.css +229 -0
- package/dist/styles/divider.css +31 -0
- package/dist/styles/header.css +95 -0
- package/dist/styles/index.css +44 -0
- package/dist/styles/input.css +264 -0
- package/dist/styles/logo.css +63 -0
- package/dist/styles/menu.css +150 -0
- package/dist/styles/nav-item.css +120 -0
- package/dist/styles/radio.css +200 -0
- package/dist/styles/scroll-progress.css +171 -0
- package/dist/styles/select.css +162 -0
- package/dist/styles/table.css +192 -0
- package/dist/styles/tabs.css +219 -0
- package/dist/styles/toggle.css +184 -0
- package/dist/styles/tokens/base.css +386 -0
- package/dist/styles/tokens/breakpoint-mobile.css +34 -0
- package/dist/styles/tokens/breakpoint-tablet.css +25 -0
- package/dist/styles/tokens/elevation.css +112 -0
- package/dist/styles/tokens/index.css +6 -0
- package/dist/styles/tokens/theme-dark.css +106 -0
- package/dist/styles/tokens/typography.css +124 -0
- package/dist/tokens/index.d.ts +2097 -0
- package/dist/tokens/index.d.ts.map +1 -0
- package/dist/tokens/index.js +1702 -0
- package/dist/tokens/index.js.map +1 -0
- 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
|
+
}
|