@oneli8/core 1.0.0-beta.3
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 +202 -0
- package/NOTICE +8 -0
- package/README.md +76 -0
- package/package.json +79 -0
- package/src/atoms/icon/icon.css +54 -0
- package/src/atoms/icon/icon.d.ts +19 -0
- package/src/atoms/icon/icon.js +81 -0
- package/src/atoms/icon/icons.generated.d.ts +38 -0
- package/src/atoms/icon/icons.generated.js +128 -0
- package/src/atoms/icon/index.d.ts +2 -0
- package/src/atoms/icon/index.js +2 -0
- package/src/atoms/index.d.ts +2 -0
- package/src/atoms/index.js +2 -0
- package/src/atoms/selection-indicator/index.d.ts +1 -0
- package/src/atoms/selection-indicator/index.js +1 -0
- package/src/atoms/selection-indicator/selection-indicator.css +140 -0
- package/src/atoms/selection-indicator/selection-indicator.d.ts +36 -0
- package/src/atoms/selection-indicator/selection-indicator.js +61 -0
- package/src/index.d.ts +3 -0
- package/src/index.js +3 -0
- package/src/materials/gem/gem.css +171 -0
- package/src/molecules/button/button.css +193 -0
- package/src/molecules/button/button.d.ts +51 -0
- package/src/molecules/button/button.js +134 -0
- package/src/molecules/button/index.d.ts +1 -0
- package/src/molecules/button/index.js +1 -0
- package/src/molecules/choice-item/choice-item.css +190 -0
- package/src/molecules/choice-item/choice-item.d.ts +54 -0
- package/src/molecules/choice-item/choice-item.js +153 -0
- package/src/molecules/choice-item/index.d.ts +1 -0
- package/src/molecules/choice-item/index.js +1 -0
- package/src/molecules/icon-button/icon-button.css +222 -0
- package/src/molecules/icon-button/icon-button.d.ts +40 -0
- package/src/molecules/icon-button/icon-button.js +121 -0
- package/src/molecules/icon-button/index.d.ts +1 -0
- package/src/molecules/icon-button/index.js +1 -0
- package/src/molecules/index.d.ts +4 -0
- package/src/molecules/index.js +4 -0
- package/src/molecules/selection-option/index.d.ts +1 -0
- package/src/molecules/selection-option/index.js +1 -0
- package/src/molecules/selection-option/selection-option.css +121 -0
- package/src/molecules/selection-option/selection-option.d.ts +42 -0
- package/src/molecules/selection-option/selection-option.js +97 -0
- package/src/organisms/choice-group/choice-group.css +93 -0
- package/src/organisms/choice-group/choice-group.d.ts +80 -0
- package/src/organisms/choice-group/choice-group.js +159 -0
- package/src/organisms/choice-group/index.d.ts +1 -0
- package/src/organisms/choice-group/index.js +1 -0
- package/src/organisms/index.d.ts +1 -0
- package/src/organisms/index.js +1 -0
- package/styles.css +13 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Oneli8 · Choice Item (MOLECULE) — headless behavior.
|
|
3
|
+
*
|
|
4
|
+
* Figma (252:301): "Native input, name/value, required, reset, form
|
|
5
|
+
* submission, and onCheckedChange live in code." This module is that code —
|
|
6
|
+
* and deliberately nothing more. It does not reimplement toggling, focus or
|
|
7
|
+
* form participation; a real <input> already does all of it.
|
|
8
|
+
*
|
|
9
|
+
* The one thing that genuinely cannot be expressed in markup or CSS is
|
|
10
|
+
* `indeterminate`, which is a JS-only property. That is why the Mixed state
|
|
11
|
+
* needs hydration and the other states do not.
|
|
12
|
+
*/
|
|
13
|
+
import { renderSelectionIndicator } from '../../atoms/selection-indicator/selection-indicator.js';
|
|
14
|
+
|
|
15
|
+
export const OL8_CHOICE_KINDS = ['checkbox', 'radio', 'switch'];
|
|
16
|
+
export const OL8_CHOICE_SIZES = ['compact', 'standard', 'comfortable', 'large'];
|
|
17
|
+
|
|
18
|
+
/** The molecule's four sizes map onto the atom's two. */
|
|
19
|
+
export function indicatorSizeFor(size) {
|
|
20
|
+
return size === 'comfortable' || size === 'large' ? 'comfortable' : 'compact';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Figma's Selection axis, derived from native input state. */
|
|
24
|
+
export function selectionStateFor(kind, { checked = false, mixed = false } = {}) {
|
|
25
|
+
if (kind === 'checkbox') return mixed ? 'mixed' : checked ? 'checked' : 'unchecked';
|
|
26
|
+
if (kind === 'radio') return checked ? 'selected' : 'unselected';
|
|
27
|
+
return checked ? 'on' : 'off';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {'checkbox'|'radio'|'switch'} kind
|
|
32
|
+
* @param {string} label
|
|
33
|
+
* @param {{size?:string,checked?:boolean,mixed?:boolean,disabled?:boolean,invalid?:boolean,
|
|
34
|
+
* description?:string,name?:string,value?:string,required?:boolean,
|
|
35
|
+
* id?:string,material?:string,className?:string}} [options]
|
|
36
|
+
*/
|
|
37
|
+
export function renderChoiceItem(kind, label, options = {}) {
|
|
38
|
+
if (!OL8_CHOICE_KINDS.includes(kind)) throw new Error(`[ol8] unknown choice kind "${kind}"`);
|
|
39
|
+
const {
|
|
40
|
+
size = 'standard', checked = false, mixed = false, disabled = false, invalid = false,
|
|
41
|
+
description, name, value, required = false, id, material = 'regular', className,
|
|
42
|
+
} = options;
|
|
43
|
+
|
|
44
|
+
if (!OL8_CHOICE_SIZES.includes(size)) throw new Error(`[ol8] unknown choice size "${size}"`);
|
|
45
|
+
if (mixed && kind !== 'checkbox') throw new Error('[ol8] only a checkbox has a Mixed state');
|
|
46
|
+
if (!label || !String(label).trim()) throw new Error('[ol8] Choice Item requires a label');
|
|
47
|
+
|
|
48
|
+
const uid = id ?? `ol8-choice-${Math.random().toString(36).slice(2, 9)}`;
|
|
49
|
+
const descId = description ? `${uid}-desc` : null;
|
|
50
|
+
const selection = selectionStateFor(kind, { checked, mixed });
|
|
51
|
+
|
|
52
|
+
const inputAttrs = [
|
|
53
|
+
`type="${kind === 'radio' ? 'radio' : 'checkbox'}"`,
|
|
54
|
+
kind === 'switch' ? 'role="switch"' : '',
|
|
55
|
+
`class="ol8-choice__input"`,
|
|
56
|
+
`id="${uid}"`,
|
|
57
|
+
name ? `name="${escapeAttr(name)}"` : '',
|
|
58
|
+
value !== undefined ? `value="${escapeAttr(value)}"` : '',
|
|
59
|
+
checked ? 'checked' : '',
|
|
60
|
+
disabled ? 'disabled' : '',
|
|
61
|
+
required ? 'required' : '',
|
|
62
|
+
invalid ? 'aria-invalid="true"' : '',
|
|
63
|
+
descId ? `aria-describedby="${descId}"` : '',
|
|
64
|
+
].filter(Boolean).join(' ');
|
|
65
|
+
|
|
66
|
+
const rowAttrs = [
|
|
67
|
+
`class="ol8-choice ol8-choice--${kind}${className ? ` ${className}` : ''}"`,
|
|
68
|
+
`for="${uid}"`,
|
|
69
|
+
`data-ol8-size="${size}"`,
|
|
70
|
+
`data-ol8-kind="${kind}"`,
|
|
71
|
+
`data-ol8-availability="${disabled ? 'disabled' : 'enabled'}"`,
|
|
72
|
+
invalid ? 'data-ol8-invalid="true"' : '',
|
|
73
|
+
mixed ? 'data-ol8-mixed="true"' : '',
|
|
74
|
+
material === 'gem' ? 'data-ol8-material="gem"' : '',
|
|
75
|
+
].filter(Boolean).join(' ');
|
|
76
|
+
|
|
77
|
+
const indicator = renderSelectionIndicator(kind, {
|
|
78
|
+
selection, size: indicatorSizeFor(size), disabled,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return `<label ${rowAttrs}>` +
|
|
82
|
+
`<input ${inputAttrs}>` +
|
|
83
|
+
`<span class="ol8-choice__surface" aria-hidden="true"></span>` +
|
|
84
|
+
`<span class="ol8-choice__indicator">${indicator}</span>` +
|
|
85
|
+
`<span class="ol8-choice__content">` +
|
|
86
|
+
`<span class="ol8-choice__label">${escapeText(label)}</span>` +
|
|
87
|
+
(description ? `<span class="ol8-choice__description" id="${descId}">${escapeText(description)}</span>` : '') +
|
|
88
|
+
`</span></label>`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Binds every `.ol8-choice` under `root`. Idempotent.
|
|
93
|
+
* Applies `indeterminate` (JS-only) and keeps the indicator in step with the
|
|
94
|
+
* input — including radios, where selecting one silently clears its siblings.
|
|
95
|
+
*/
|
|
96
|
+
export function hydrateChoiceItems(root = document) {
|
|
97
|
+
const rows = [...root.querySelectorAll('.ol8-choice')];
|
|
98
|
+
|
|
99
|
+
for (const row of rows) {
|
|
100
|
+
const input = row.querySelector(':scope > .ol8-choice__input');
|
|
101
|
+
if (!input) continue;
|
|
102
|
+
|
|
103
|
+
input.indeterminate = row.dataset.ol8Mixed === 'true';
|
|
104
|
+
syncIndicator(row);
|
|
105
|
+
|
|
106
|
+
if (row.dataset.ol8ChoiceBound === 'true') continue;
|
|
107
|
+
input.addEventListener('change', () => {
|
|
108
|
+
// Any interaction resolves Mixed; the browser has already cleared
|
|
109
|
+
// `indeterminate` on click, so mirror that in our own state.
|
|
110
|
+
delete row.dataset.ol8Mixed;
|
|
111
|
+
syncIndicator(row);
|
|
112
|
+
// A radio turning on turns its whole group off — repaint the siblings.
|
|
113
|
+
if (input.type === 'radio' && input.name) {
|
|
114
|
+
for (const other of (row.ownerDocument ?? document)
|
|
115
|
+
.querySelectorAll(`input.ol8-choice__input[type="radio"][name="${CSS.escape(input.name)}"]`)) {
|
|
116
|
+
const otherRow = other.closest('.ol8-choice');
|
|
117
|
+
if (otherRow && otherRow !== row) syncIndicator(otherRow);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
row.dataset.ol8ChoiceBound = 'true';
|
|
122
|
+
}
|
|
123
|
+
return rows.length;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Repaints one row's indicator from the live state of its input. */
|
|
127
|
+
export function syncIndicator(row) {
|
|
128
|
+
const input = row.querySelector(':scope > .ol8-choice__input');
|
|
129
|
+
const slot = row.querySelector(':scope > .ol8-choice__indicator');
|
|
130
|
+
if (!input || !slot) return;
|
|
131
|
+
|
|
132
|
+
const kind = row.dataset.ol8Kind ?? 'checkbox';
|
|
133
|
+
const selection = selectionStateFor(kind, { checked: input.checked, mixed: input.indeterminate });
|
|
134
|
+
|
|
135
|
+
row.dataset.ol8Availability = input.disabled ? 'disabled' : 'enabled';
|
|
136
|
+
slot.innerHTML = renderSelectionIndicator(kind, {
|
|
137
|
+
selection,
|
|
138
|
+
size: indicatorSizeFor(row.dataset.ol8Size ?? 'standard'),
|
|
139
|
+
disabled: input.disabled,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Sets the Mixed (indeterminate) state on a checkbox row. */
|
|
144
|
+
export function setChoiceMixed(row, mixed) {
|
|
145
|
+
const input = row.querySelector(':scope > .ol8-choice__input');
|
|
146
|
+
if (!input) return;
|
|
147
|
+
input.indeterminate = !!mixed;
|
|
148
|
+
if (mixed) row.dataset.ol8Mixed = 'true'; else delete row.dataset.ol8Mixed;
|
|
149
|
+
syncIndicator(row);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function escapeAttr(v) { return String(v).replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<'); }
|
|
153
|
+
function escapeText(v) { return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './choice-item.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './choice-item.js';
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Oneli8 · Icon Button
|
|
3
|
+
* Figma: section "Icon Button" (1034:10546) — Primary 86:2, Secondary 87:26,
|
|
4
|
+
* Quiet 88:50, Destructive 89:74, plus the four Circle sets.
|
|
5
|
+
* 4 families × 2 shapes × 4 sizes × 6 states = 192 components.
|
|
6
|
+
*
|
|
7
|
+
* Documentation (85:9):
|
|
8
|
+
* "Visible boxes: 36/42/48/60px · protected targets: 48/48/48/60px
|
|
9
|
+
* Artwork: 18/18/24/36px
|
|
10
|
+
* Hover changes the container only; the symbol never translates, rotates
|
|
11
|
+
* or scales. Accessible name is mandatory. Rounded Square is the canonical
|
|
12
|
+
* base; Circle is a governed presentation extension."
|
|
13
|
+
*
|
|
14
|
+
* The component is TWO boxes, and that is the whole point:
|
|
15
|
+
* .ol8-icon-btn — the protected TARGET. Transparent, no paint.
|
|
16
|
+
* Never smaller than 48px even when the visible
|
|
17
|
+
* box is 36px, so the hit area stays compliant.
|
|
18
|
+
* .ol8-icon-btn__surface — the VISIBLE box. Carries bg, 2px border, radius.
|
|
19
|
+
*
|
|
20
|
+
* Collapsing these into one element would silently shrink the touch target
|
|
21
|
+
* to 36px at Compact. Don't.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
.ol8-icon-btn {
|
|
25
|
+
--_target: var(--ol8-component-iconbutton-target-standard);
|
|
26
|
+
--_box: var(--ol8-component-iconbutton-box-standard);
|
|
27
|
+
--_radius: var(--ol8-component-iconbutton-radius-standard);
|
|
28
|
+
--_artwork: var(--ol8-component-iconbutton-artwork-standard);
|
|
29
|
+
|
|
30
|
+
--_bg: transparent;
|
|
31
|
+
--_bd: transparent;
|
|
32
|
+
--_ink: var(--ol8-color-icon-primary);
|
|
33
|
+
|
|
34
|
+
box-sizing: border-box;
|
|
35
|
+
display: inline-flex;
|
|
36
|
+
align-items: center;
|
|
37
|
+
justify-content: center;
|
|
38
|
+
inline-size: var(--_target);
|
|
39
|
+
block-size: var(--_target);
|
|
40
|
+
padding: 0;
|
|
41
|
+
border: 0;
|
|
42
|
+
background: none; /* the target paints nothing */
|
|
43
|
+
cursor: pointer;
|
|
44
|
+
-webkit-appearance: none;
|
|
45
|
+
appearance: none;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.ol8-icon-btn__surface {
|
|
49
|
+
box-sizing: border-box;
|
|
50
|
+
display: inline-flex;
|
|
51
|
+
align-items: center;
|
|
52
|
+
justify-content: center;
|
|
53
|
+
inline-size: var(--_box);
|
|
54
|
+
block-size: var(--_box);
|
|
55
|
+
border: var(--ol8-border-width-standard) solid var(--_bd);
|
|
56
|
+
border-radius: var(--_radius);
|
|
57
|
+
background: var(--_bg);
|
|
58
|
+
color: var(--_ink);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* The surface governs artwork size — never set a size on the glyph. */
|
|
62
|
+
.ol8-icon-btn .ol8-icon { --_ol8-icon-size: var(--_artwork); }
|
|
63
|
+
|
|
64
|
+
/* "the symbol never translates, rotates, or scales" — no transform on the
|
|
65
|
+
glyph in any state. Hover changes the container only. */
|
|
66
|
+
|
|
67
|
+
/* ---- Size ------------------------------------------------------------ */
|
|
68
|
+
.ol8-icon-btn[data-ol8-size="compact"] {
|
|
69
|
+
--_target: var(--ol8-component-iconbutton-target-compact);
|
|
70
|
+
--_box: var(--ol8-component-iconbutton-box-compact);
|
|
71
|
+
--_radius: var(--ol8-component-iconbutton-radius-compact);
|
|
72
|
+
--_artwork: var(--ol8-component-iconbutton-artwork-compact);
|
|
73
|
+
}
|
|
74
|
+
.ol8-icon-btn[data-ol8-size="standard"] {
|
|
75
|
+
--_target: var(--ol8-component-iconbutton-target-standard);
|
|
76
|
+
--_box: var(--ol8-component-iconbutton-box-standard);
|
|
77
|
+
--_radius: var(--ol8-component-iconbutton-radius-standard);
|
|
78
|
+
--_artwork: var(--ol8-component-iconbutton-artwork-standard);
|
|
79
|
+
}
|
|
80
|
+
.ol8-icon-btn[data-ol8-size="comfortable"] {
|
|
81
|
+
--_target: var(--ol8-component-iconbutton-target-comfortable);
|
|
82
|
+
--_box: var(--ol8-component-iconbutton-box-comfortable);
|
|
83
|
+
--_radius: var(--ol8-component-iconbutton-radius-comfortable);
|
|
84
|
+
--_artwork: var(--ol8-component-iconbutton-artwork-comfortable);
|
|
85
|
+
}
|
|
86
|
+
.ol8-icon-btn[data-ol8-size="large"] {
|
|
87
|
+
--_target: var(--ol8-component-iconbutton-target-large);
|
|
88
|
+
--_box: var(--ol8-component-iconbutton-box-large);
|
|
89
|
+
--_radius: var(--ol8-component-iconbutton-radius-large);
|
|
90
|
+
--_artwork: var(--ol8-component-iconbutton-artwork-large);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* ---- Shape: Circle is a governed presentation extension -------------- */
|
|
94
|
+
.ol8-icon-btn[data-ol8-shape="circle"] { --_radius: var(--ol8-shape-radius-full); }
|
|
95
|
+
|
|
96
|
+
/* ---- Family: Primary ------------------------------------------------- */
|
|
97
|
+
/* The glyph binds to the action's CONTENT colour, not the neutral ink role.
|
|
98
|
+
Figma binds --ol8-color-icon-primary (#151817) here, which lands a near-black
|
|
99
|
+
glyph on a #2c438b surface: 1.94:1, against the 3:1 WCAG 1.4.11 minimum for
|
|
100
|
+
graphical objects. --ol8-color-actionprimary-content measures 8.81:1. */
|
|
101
|
+
.ol8-icon-btn--primary {
|
|
102
|
+
--_bg: var(--ol8-color-actionprimary-default);
|
|
103
|
+
--_bd: var(--ol8-color-actionprimary-default);
|
|
104
|
+
--_ink: var(--ol8-color-actionprimary-content);
|
|
105
|
+
}
|
|
106
|
+
.ol8-icon-btn--primary:hover:not(:disabled):not([data-ol8-loading]) > .ol8-icon-btn__surface,
|
|
107
|
+
.ol8-icon-btn--primary[data-ol8-loading] > .ol8-icon-btn__surface {
|
|
108
|
+
--_bg: var(--ol8-color-actionprimary-hover); --_bd: var(--ol8-color-actionprimary-hover);
|
|
109
|
+
}
|
|
110
|
+
.ol8-icon-btn--primary:active:not(:disabled) > .ol8-icon-btn__surface,
|
|
111
|
+
.ol8-icon-btn--primary[aria-pressed="true"]:not(:disabled) > .ol8-icon-btn__surface {
|
|
112
|
+
--_bg: var(--ol8-color-actionprimary-pressed); --_bd: var(--ol8-color-actionprimary-pressed);
|
|
113
|
+
}
|
|
114
|
+
.ol8-icon-btn--primary:disabled > .ol8-icon-btn__surface {
|
|
115
|
+
--_bg: var(--ol8-color-actionprimary-disabled);
|
|
116
|
+
--_bd: var(--ol8-color-border-disabled);
|
|
117
|
+
/* Disabled flips to a LIGHT surface, so the glyph flips back to dark ink. */
|
|
118
|
+
--_ink: var(--ol8-color-actionprimary-disabledcontent);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* ---- Family: Destructive --------------------------------------------- */
|
|
122
|
+
/* Same defect as Primary: #151817 on #922824 measures 2.17:1. */
|
|
123
|
+
.ol8-icon-btn--destructive {
|
|
124
|
+
--_bg: var(--ol8-color-actiondestructive-default);
|
|
125
|
+
--_bd: var(--ol8-color-actiondestructive-default);
|
|
126
|
+
--_ink: var(--ol8-color-actiondestructive-content);
|
|
127
|
+
}
|
|
128
|
+
.ol8-icon-btn--destructive:hover:not(:disabled):not([data-ol8-loading]) > .ol8-icon-btn__surface,
|
|
129
|
+
.ol8-icon-btn--destructive[data-ol8-loading] > .ol8-icon-btn__surface {
|
|
130
|
+
--_bg: var(--ol8-color-actiondestructive-hover); --_bd: var(--ol8-color-actiondestructive-hover);
|
|
131
|
+
}
|
|
132
|
+
.ol8-icon-btn--destructive:active:not(:disabled) > .ol8-icon-btn__surface,
|
|
133
|
+
.ol8-icon-btn--destructive[aria-pressed="true"]:not(:disabled) > .ol8-icon-btn__surface {
|
|
134
|
+
--_bg: var(--ol8-color-actiondestructive-pressed); --_bd: var(--ol8-color-actiondestructive-pressed);
|
|
135
|
+
}
|
|
136
|
+
.ol8-icon-btn--destructive:disabled > .ol8-icon-btn__surface {
|
|
137
|
+
--_bg: var(--ol8-color-actiondestructive-disabled);
|
|
138
|
+
--_bd: var(--ol8-color-border-disabled);
|
|
139
|
+
--_ink: var(--ol8-color-actiondestructive-disabledcontent);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* ---- Family: Secondary (bordered, transparent) ----------------------- */
|
|
143
|
+
.ol8-icon-btn--secondary {
|
|
144
|
+
--_bg: var(--ol8-color-actionsecondary-surfacedefault);
|
|
145
|
+
--_bd: var(--ol8-color-actionsecondary-contentdefault);
|
|
146
|
+
--_ink: var(--ol8-color-actionsecondary-contentdefault);
|
|
147
|
+
}
|
|
148
|
+
.ol8-icon-btn--secondary:hover:not(:disabled):not([data-ol8-loading]) > .ol8-icon-btn__surface,
|
|
149
|
+
.ol8-icon-btn--secondary[data-ol8-loading] > .ol8-icon-btn__surface {
|
|
150
|
+
--_bg: var(--ol8-color-actionsecondary-surfacehover);
|
|
151
|
+
--_bd: var(--ol8-color-actionsecondary-contenthover);
|
|
152
|
+
--_ink: var(--ol8-color-actionsecondary-contenthover);
|
|
153
|
+
}
|
|
154
|
+
.ol8-icon-btn--secondary:active:not(:disabled) > .ol8-icon-btn__surface,
|
|
155
|
+
.ol8-icon-btn--secondary[aria-pressed="true"]:not(:disabled) > .ol8-icon-btn__surface {
|
|
156
|
+
--_bg: var(--ol8-color-actionsecondary-surfacepressed);
|
|
157
|
+
--_bd: var(--ol8-color-actionsecondary-contentpressed);
|
|
158
|
+
--_ink: var(--ol8-color-actionsecondary-contentpressed);
|
|
159
|
+
}
|
|
160
|
+
.ol8-icon-btn--secondary:disabled > .ol8-icon-btn__surface {
|
|
161
|
+
--_bg: var(--ol8-color-actionsecondary-surfacedisabled);
|
|
162
|
+
--_bd: var(--ol8-color-border-disabled);
|
|
163
|
+
--_ink: var(--ol8-color-actionsecondary-contentdisabled);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/* ---- Family: Quiet (no boundary) ------------------------------------- */
|
|
167
|
+
.ol8-icon-btn--quiet {
|
|
168
|
+
--_bg: var(--ol8-color-actionquiet-surfacedefault);
|
|
169
|
+
--_bd: transparent;
|
|
170
|
+
--_ink: var(--ol8-color-actionquiet-contentdefault);
|
|
171
|
+
}
|
|
172
|
+
.ol8-icon-btn--quiet:hover:not(:disabled):not([data-ol8-loading]) > .ol8-icon-btn__surface,
|
|
173
|
+
.ol8-icon-btn--quiet[data-ol8-loading] > .ol8-icon-btn__surface {
|
|
174
|
+
--_bg: var(--ol8-color-actionquiet-surfacehover); --_ink: var(--ol8-color-actionquiet-contenthover);
|
|
175
|
+
}
|
|
176
|
+
.ol8-icon-btn--quiet:active:not(:disabled) > .ol8-icon-btn__surface,
|
|
177
|
+
.ol8-icon-btn--quiet[aria-pressed="true"]:not(:disabled) > .ol8-icon-btn__surface {
|
|
178
|
+
--_bg: var(--ol8-color-actionquiet-surfacepressed); --_ink: var(--ol8-color-actionquiet-contentpressed);
|
|
179
|
+
}
|
|
180
|
+
.ol8-icon-btn--quiet:disabled > .ol8-icon-btn__surface {
|
|
181
|
+
--_bg: var(--ol8-color-actionquiet-surfacedisabled); --_ink: var(--ol8-color-actionquiet-contentdisabled);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/* ---- Pressed scrim ---------------------------------------------------
|
|
185
|
+
Figma layers a 3px inset scrim over the pressed surface, in addition to
|
|
186
|
+
the darker fill. Reproduced as an inset shadow rather than an extra node. */
|
|
187
|
+
.ol8-icon-btn:active:not(:disabled) > .ol8-icon-btn__surface,
|
|
188
|
+
.ol8-icon-btn[aria-pressed="true"]:not(:disabled) > .ol8-icon-btn__surface {
|
|
189
|
+
box-shadow: inset 0 0 0 3px var(--ol8-component-iconbutton-pressedscrim);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/* ---- Focus -----------------------------------------------------------
|
|
193
|
+
OUTSIDE two-tone ring, unlike the labelled Button's inside stroke.
|
|
194
|
+
|
|
195
|
+
Figma declares the 6px outer shadow FIRST, and box-shadow paints the first
|
|
196
|
+
shadow on top — so the 6px dark spread completely covers the 3px light one
|
|
197
|
+
and only a flat dark band renders. Sampling the Figma export confirms it:
|
|
198
|
+
uniform #101b3a at every perimeter position and at both 2px and 4px depth.
|
|
199
|
+
|
|
200
|
+
The inner band is declared here first so both are visible: light from the
|
|
201
|
+
edge out to 3px, dark from 3px to 6px. That is the ring the two tokens
|
|
202
|
+
describe. To restore the Figma-exact single-band ring, swap these two
|
|
203
|
+
lines back.
|
|
204
|
+
|
|
205
|
+
Both focus-ring tokens are BAND THICKNESSES (3px each). box-shadow spread is
|
|
206
|
+
cumulative from the edge, so the outer band's spread is the sum of the two —
|
|
207
|
+
3px light from the edge, then dark from 3px out to 6px. Do not pass
|
|
208
|
+
--ol8-focus-ring-outerwidth here on its own: that would paint the dark band
|
|
209
|
+
at the same 3px radius as the light one and hide it completely. */
|
|
210
|
+
.ol8-icon-btn:focus-visible { outline: none; }
|
|
211
|
+
.ol8-icon-btn:focus-visible > .ol8-icon-btn__surface {
|
|
212
|
+
box-shadow:
|
|
213
|
+
0 0 0 var(--ol8-focus-ring-innerwidth) var(--ol8-color-focus-inner),
|
|
214
|
+
0 0 0 calc(var(--ol8-focus-ring-innerwidth) + var(--ol8-focus-ring-outerwidth)) var(--ol8-color-focus-outer);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/* ---- Disabled / Loading ---------------------------------------------- */
|
|
218
|
+
.ol8-icon-btn:disabled { cursor: not-allowed; }
|
|
219
|
+
.ol8-icon-btn[data-ol8-loading] { cursor: progress; }
|
|
220
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
221
|
+
.ol8-icon-btn[data-ol8-loading] .ol8-icon > svg { animation: ol8-spin 900ms linear infinite; }
|
|
222
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Icon Button (MOLECULE) — headless behavior. An accessible name is mandatory
|
|
3
|
+
* and enforced here rather than left to reviewers.
|
|
4
|
+
*/
|
|
5
|
+
import type { Ol8IconName } from '../../atoms/icon/icons.generated.js';
|
|
6
|
+
|
|
7
|
+
export type Ol8IconButtonVariant = 'primary' | 'secondary' | 'quiet' | 'destructive';
|
|
8
|
+
export type Ol8IconButtonSize = 'compact' | 'standard' | 'comfortable' | 'large';
|
|
9
|
+
export type Ol8IconButtonShape = 'rounded' | 'circle';
|
|
10
|
+
|
|
11
|
+
export declare const OL8_ICON_BUTTON_VARIANTS: readonly Ol8IconButtonVariant[];
|
|
12
|
+
export declare const OL8_ICON_BUTTON_SIZES: readonly Ol8IconButtonSize[];
|
|
13
|
+
export declare const OL8_ICON_BUTTON_SHAPES: readonly Ol8IconButtonShape[];
|
|
14
|
+
|
|
15
|
+
/** Artwork sizes as BUILT in Figma: 18 / 18 / 24 / 24. */
|
|
16
|
+
export declare function artworkSizeForIconButton(size: Ol8IconButtonSize): 18 | 24;
|
|
17
|
+
|
|
18
|
+
export interface Ol8IconButtonOptions {
|
|
19
|
+
variant?: Ol8IconButtonVariant;
|
|
20
|
+
size?: Ol8IconButtonSize;
|
|
21
|
+
shape?: Ol8IconButtonShape;
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
loading?: boolean;
|
|
24
|
+
pressed?: boolean;
|
|
25
|
+
type?: 'button' | 'submit' | 'reset';
|
|
26
|
+
material?: 'regular' | 'gem';
|
|
27
|
+
className?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param icon registry name, e.g. "add"
|
|
32
|
+
* @param label accessible name — REQUIRED, throws if empty
|
|
33
|
+
*/
|
|
34
|
+
export declare function renderIconButton(
|
|
35
|
+
icon: Ol8IconName,
|
|
36
|
+
label: string,
|
|
37
|
+
options?: Ol8IconButtonOptions,
|
|
38
|
+
): string;
|
|
39
|
+
|
|
40
|
+
export declare function hydrateIconButtons(root?: ParentNode): void;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Oneli8 · Icon Button — headless behavior.
|
|
3
|
+
*
|
|
4
|
+
* Figma (86:2): "Icon is an INSTANCE_SWAP sourced from the governed 24px Icon
|
|
5
|
+
* Library atom and resized only through Icon Frame; Accessible Name is
|
|
6
|
+
* mandatory. Size and State are deterministic visual evidence. Runtime
|
|
7
|
+
* disabled, loading, pressed, motion, tooltip policy, and native button
|
|
8
|
+
* semantics live in code."
|
|
9
|
+
*
|
|
10
|
+
* "Accessible Name is mandatory" is enforced here, not left to reviewers.
|
|
11
|
+
*/
|
|
12
|
+
import { renderIcon, renderIconSvg, isOl8IconName } from '../../atoms/icon/icon.js';
|
|
13
|
+
import { GEM_VARIANTS } from '../button/button.js';
|
|
14
|
+
|
|
15
|
+
export const OL8_ICON_BUTTON_VARIANTS = ['primary', 'secondary', 'quiet', 'destructive'];
|
|
16
|
+
export const OL8_ICON_BUTTON_SIZES = ['compact', 'standard', 'comfortable', 'large'];
|
|
17
|
+
export const OL8_ICON_BUTTON_SHAPES = ['rounded', 'circle'];
|
|
18
|
+
|
|
19
|
+
/** Artwork sizes as BUILT in Figma: 18 / 18 / 24 / 24. */
|
|
20
|
+
export function artworkSizeForIconButton(size) {
|
|
21
|
+
return size === 'comfortable' || size === 'large' ? 24 : 18;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {string} icon registry name, e.g. "add"
|
|
26
|
+
* @param {string} label accessible name — REQUIRED
|
|
27
|
+
* @param {{variant?:string,size?:string,shape?:string,disabled?:boolean,
|
|
28
|
+
* loading?:boolean,pressed?:boolean,type?:string,className?:string}} [options]
|
|
29
|
+
*/
|
|
30
|
+
export function renderIconButton(icon, label, options = {}) {
|
|
31
|
+
const {
|
|
32
|
+
variant = 'primary', size = 'standard', shape = 'rounded',
|
|
33
|
+
disabled = false, loading = false, pressed, type = 'button', className, material = 'regular',
|
|
34
|
+
} = options;
|
|
35
|
+
|
|
36
|
+
if (!isOl8IconName(icon)) throw new Error(`[ol8] unknown icon "${icon}"`);
|
|
37
|
+
if (!OL8_ICON_BUTTON_VARIANTS.includes(variant)) throw new Error(`[ol8] unknown icon-button variant "${variant}"`);
|
|
38
|
+
if (!OL8_ICON_BUTTON_SIZES.includes(size)) throw new Error(`[ol8] unknown icon-button size "${size}"`);
|
|
39
|
+
if (!OL8_ICON_BUTTON_SHAPES.includes(shape)) throw new Error(`[ol8] unknown icon-button shape "${shape}"`);
|
|
40
|
+
if (material === 'gem' && !GEM_VARIANTS.includes(variant)) {
|
|
41
|
+
throw new Error(`[ol8] Gem has no approved treatment for "${variant}" — Figma 100:3 says Quiet and Destructive use the regular material.`);
|
|
42
|
+
}
|
|
43
|
+
if (!label || !String(label).trim()) {
|
|
44
|
+
throw new Error('[ol8] Icon Button requires an accessible name — Figma marks it mandatory (86:2).');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const artwork = artworkSizeForIconButton(size);
|
|
48
|
+
const glyph = loading
|
|
49
|
+
? `<span class="ol8-icon" aria-hidden="true">${renderIconSvg('loading')}</span>`
|
|
50
|
+
: renderIcon(icon, { size: artwork });
|
|
51
|
+
|
|
52
|
+
const attrs = [
|
|
53
|
+
`class="ol8-icon-btn ol8-icon-btn--${variant}${className ? ` ${className}` : ''}"`,
|
|
54
|
+
`type="${type}"`,
|
|
55
|
+
`data-ol8-size="${size}"`,
|
|
56
|
+
shape === 'circle' ? 'data-ol8-shape="circle"' : '',
|
|
57
|
+
`aria-label="${escapeAttr(label)}"`,
|
|
58
|
+
disabled || loading ? 'disabled' : '',
|
|
59
|
+
loading ? 'data-ol8-loading="true" aria-busy="true"' : '',
|
|
60
|
+
pressed !== undefined ? `aria-pressed="${pressed}"` : '',
|
|
61
|
+
material === 'gem' ? 'data-ol8-material="gem"' : '',
|
|
62
|
+
].filter(Boolean).join(' ');
|
|
63
|
+
|
|
64
|
+
return `<button ${attrs}><span class="ol8-icon-btn__surface">${glyph}</span></button>`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Enforces the runtime contract on existing `.ol8-icon-btn` markup. Idempotent.
|
|
69
|
+
* Wraps a bare glyph in the required surface, and reports any button that has
|
|
70
|
+
* no accessible name — the one rule Figma calls mandatory.
|
|
71
|
+
*/
|
|
72
|
+
export function hydrateIconButtons(root = document) {
|
|
73
|
+
const unnamed = [];
|
|
74
|
+
for (const el of root.querySelectorAll('.ol8-icon-btn')) {
|
|
75
|
+
if (!el.hasAttribute('type') && el.tagName === 'BUTTON') el.setAttribute('type', 'button');
|
|
76
|
+
|
|
77
|
+
// The protected target must always wrap a visible surface.
|
|
78
|
+
if (!el.querySelector(':scope > .ol8-icon-btn__surface')) {
|
|
79
|
+
const surface = document.createElement('span');
|
|
80
|
+
surface.className = 'ol8-icon-btn__surface';
|
|
81
|
+
while (el.firstChild) surface.appendChild(el.firstChild);
|
|
82
|
+
el.appendChild(surface);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const loading = el.dataset.ol8Loading === 'true';
|
|
86
|
+
if (loading) {
|
|
87
|
+
el.setAttribute('aria-busy', 'true');
|
|
88
|
+
el.disabled = true;
|
|
89
|
+
const surface = el.querySelector(':scope > .ol8-icon-btn__surface');
|
|
90
|
+
if (!surface.querySelector('[data-ol8-loading-glyph]')) {
|
|
91
|
+
surface.innerHTML = `<span class="ol8-icon" aria-hidden="true" data-ol8-loading-glyph="true">${renderIconSvg('loading')}</span>`;
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
el.removeAttribute('aria-busy');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!accessibleName(el)) unnamed.push(el);
|
|
98
|
+
}
|
|
99
|
+
if (unnamed.length) {
|
|
100
|
+
console.error(`[ol8] ${unnamed.length} icon button(s) have no accessible name; Figma marks it mandatory.`, unnamed);
|
|
101
|
+
}
|
|
102
|
+
return { unnamed };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function accessibleName(el) {
|
|
106
|
+
const aria = el.getAttribute('aria-label');
|
|
107
|
+
if (aria && aria.trim()) return aria.trim();
|
|
108
|
+
const labelledby = el.getAttribute('aria-labelledby');
|
|
109
|
+
if (labelledby) {
|
|
110
|
+
const text = labelledby.split(/\s+/)
|
|
111
|
+
.map(id => el.ownerDocument.getElementById(id)?.textContent ?? '')
|
|
112
|
+
.join(' ').trim();
|
|
113
|
+
if (text) return text;
|
|
114
|
+
}
|
|
115
|
+
// Visible text that is not aria-hidden also names the control.
|
|
116
|
+
const clone = el.cloneNode(true);
|
|
117
|
+
for (const h of clone.querySelectorAll('[aria-hidden="true"]')) h.remove();
|
|
118
|
+
return clone.textContent.trim();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function escapeAttr(v) { return String(v).replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<'); }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './icon-button.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './icon-button.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './selection-option.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './selection-option.js';
|