@oneli8/core 1.0.0-beta.10
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/AGENTS.md +16 -0
- package/LICENSE +202 -0
- package/NOTICE +8 -0
- package/PRINCIPLES.md +48 -0
- package/README.md +130 -0
- package/ai-context.json +348 -0
- package/package.json +100 -0
- package/skills/oneli8-figma-to-code/SKILL.md +25 -0
- package/skills/oneli8-icons/SKILL.md +36 -0
- package/skills/oneli8-ui/SKILL.md +52 -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 +48 -0
- package/src/atoms/icon/icons.generated.js +562 -0
- package/src/atoms/icon/index.d.ts +2 -0
- package/src/atoms/icon/index.js +2 -0
- package/src/atoms/index.d.ts +3 -0
- package/src/atoms/index.js +3 -0
- package/src/atoms/navigation-badge/index.d.ts +1 -0
- package/src/atoms/navigation-badge/index.js +1 -0
- package/src/atoms/navigation-badge/navigation-badge.css +35 -0
- package/src/atoms/navigation-badge/navigation-badge.d.ts +5 -0
- package/src/atoms/navigation-badge/navigation-badge.js +21 -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 +160 -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 +374 -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-chip/choice-chip.css +214 -0
- package/src/molecules/choice-chip/choice-chip.d.ts +27 -0
- package/src/molecules/choice-chip/choice-chip.js +170 -0
- package/src/molecules/choice-chip/index.d.ts +2 -0
- package/src/molecules/choice-chip/index.js +10 -0
- package/src/molecules/choice-item/choice-item.css +265 -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/form-message/form-message.css +46 -0
- package/src/molecules/form-message/form-message.d.ts +23 -0
- package/src/molecules/form-message/form-message.js +73 -0
- package/src/molecules/form-message/index.d.ts +1 -0
- package/src/molecules/form-message/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 +10 -0
- package/src/molecules/index.js +10 -0
- package/src/molecules/link/index.d.ts +1 -0
- package/src/molecules/link/index.js +1 -0
- package/src/molecules/link/link.css +114 -0
- package/src/molecules/link/link.d.ts +27 -0
- package/src/molecules/link/link.js +76 -0
- package/src/molecules/navigation-item/index.js +1 -0
- package/src/molecules/navigation-item/navigation-item.css +153 -0
- package/src/molecules/navigation-item/navigation-item.js +95 -0
- package/src/molecules/select/index.js +1 -0
- package/src/molecules/select/select.css +31 -0
- package/src/molecules/select/select.js +127 -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/molecules/text-field/index.d.ts +1 -0
- package/src/molecules/text-field/index.js +5 -0
- package/src/molecules/text-field/text-field.css +266 -0
- package/src/molecules/text-field/text-field.d.ts +69 -0
- package/src/molecules/text-field/text-field.js +234 -0
- package/src/molecules/token/index.d.ts +2 -0
- package/src/molecules/token/index.js +1 -0
- package/src/molecules/token/token.css +63 -0
- package/src/molecules/token/token.d.ts +35 -0
- package/src/molecules/token/token.js +83 -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/choice-picker/choice-picker.css +98 -0
- package/src/organisms/choice-picker/choice-picker.d.ts +62 -0
- package/src/organisms/choice-picker/choice-picker.js +201 -0
- package/src/organisms/choice-picker/index.d.ts +1 -0
- package/src/organisms/choice-picker/index.js +1 -0
- package/src/organisms/combobox/combobox.css +40 -0
- package/src/organisms/combobox/combobox.js +309 -0
- package/src/organisms/combobox/index.js +5 -0
- package/src/organisms/index.d.ts +8 -0
- package/src/organisms/index.js +8 -0
- package/src/organisms/multi-select-field/index.d.ts +1 -0
- package/src/organisms/multi-select-field/index.js +1 -0
- package/src/organisms/multi-select-field/multi-select-field.css +76 -0
- package/src/organisms/multi-select-field/multi-select-field.d.ts +64 -0
- package/src/organisms/multi-select-field/multi-select-field.js +317 -0
- package/src/organisms/navigation-keys.js +58 -0
- package/src/organisms/segmented-control/index.d.ts +1 -0
- package/src/organisms/segmented-control/index.js +1 -0
- package/src/organisms/segmented-control/segmented-control.css +113 -0
- package/src/organisms/segmented-control/segmented-control.d.ts +43 -0
- package/src/organisms/segmented-control/segmented-control.js +163 -0
- package/src/organisms/selection-popup/index.js +1 -0
- package/src/organisms/selection-popup/selection-popup.css +90 -0
- package/src/organisms/selection-popup/selection-popup.js +100 -0
- package/src/organisms/tab-bar/index.d.ts +1 -0
- package/src/organisms/tab-bar/index.js +1 -0
- package/src/organisms/tab-bar/tab-bar.css +45 -0
- package/src/organisms/tab-bar/tab-bar.d.ts +37 -0
- package/src/organisms/tab-bar/tab-bar.js +104 -0
- package/src/organisms/tabs/index.d.ts +1 -0
- package/src/organisms/tabs/index.js +1 -0
- package/src/organisms/tabs/tabs.css +53 -0
- package/src/organisms/tabs/tabs.d.ts +48 -0
- package/src/organisms/tabs/tabs.js +152 -0
- package/styles.css +29 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Oneli8 · Text Field — headless behavior.
|
|
3
|
+
*
|
|
4
|
+
* Two sources govern this file and they govern different things.
|
|
5
|
+
*
|
|
6
|
+
* Figma (182:33 Outline, 186:53 Filled, 211:36 and 215:226 Gem) governs what is
|
|
7
|
+
* drawn: anatomy, geometry, colour and material. Its description is explicit
|
|
8
|
+
* that "runtime input type, name, value, autofill, IME, validationStatus,
|
|
9
|
+
* grapheme limiting, required/disabled/readOnly and native input semantics live
|
|
10
|
+
* in code". Its five Conditions are review evidence, never classes: here they
|
|
11
|
+
* are :hover, aria-invalid, readonly and :disabled on the real input.
|
|
12
|
+
*
|
|
13
|
+
* The Text Field contract governs what the API means: the property vocabulary,
|
|
14
|
+
* the defaults, the state precedence and the prohibitions. Figma has no opinion
|
|
15
|
+
* on those, so there is nothing to reconcile.
|
|
16
|
+
*/
|
|
17
|
+
import { renderIcon } from '../../atoms/icon/index.js';
|
|
18
|
+
import { renderFormMessage } from '../form-message/index.js';
|
|
19
|
+
|
|
20
|
+
export const OL8_TEXT_FIELD_SIZES = ['compact', 'standard', 'comfortable', 'large'];
|
|
21
|
+
export const OL8_TEXT_FIELD_APPEARANCES = ['outline', 'filled'];
|
|
22
|
+
export const OL8_TEXT_FIELD_MATERIALS = ['regular', 'gem'];
|
|
23
|
+
|
|
24
|
+
/** The four semantic tones a message can carry. Pending is not one of them. */
|
|
25
|
+
export const OL8_MESSAGE_TONES = ['critical', 'caution', 'positive', 'informative'];
|
|
26
|
+
|
|
27
|
+
/** "Pending is a validation process status, not a fifth message tone." */
|
|
28
|
+
export const OL8_VALIDATION_STATUSES = ['idle', 'pending', 'resolved'];
|
|
29
|
+
|
|
30
|
+
/** Soft lets a person overshoot and then correct. Hard is for real technical limits. */
|
|
31
|
+
export const OL8_CHARACTER_LIMIT_BEHAVIORS = ['soft', 'hard'];
|
|
32
|
+
|
|
33
|
+
let sequence = 0;
|
|
34
|
+
const nextId = () => `ol8-field-${++sequence}`;
|
|
35
|
+
|
|
36
|
+
/** @param {import('./text-field.js').Ol8TextFieldOptions} options */
|
|
37
|
+
export function renderTextField(options = {}) {
|
|
38
|
+
const {
|
|
39
|
+
label, name, id = nextId(), type = 'text', value, placeholder,
|
|
40
|
+
size = 'comfortable', appearance = 'outline', material = 'regular',
|
|
41
|
+
showLabel = true, required = false, disabled = false, readOnly = false, invalid = false,
|
|
42
|
+
leadingIcon, prefix, suffix, trailingAction,
|
|
43
|
+
instruction, message, messageTone = 'critical', messageIcon = true,
|
|
44
|
+
validationStatus = 'idle', characterLimit, characterLimitBehavior = 'soft',
|
|
45
|
+
className,
|
|
46
|
+
} = options;
|
|
47
|
+
|
|
48
|
+
assertContract({ label, size, appearance, material, messageTone, validationStatus,
|
|
49
|
+
characterLimit, characterLimitBehavior, readOnly, invalid });
|
|
50
|
+
|
|
51
|
+
const instructionId = instruction ? `${id}-instruction` : null;
|
|
52
|
+
const messageId = message ? `${id}-message` : null;
|
|
53
|
+
const counterId = characterLimit !== undefined ? `${id}-counter` : null;
|
|
54
|
+
const describedBy = [instructionId, messageId, counterId].filter(Boolean).join(' ');
|
|
55
|
+
|
|
56
|
+
const labelRow = showLabel
|
|
57
|
+
? `<div class="ol8-field__label-row">` +
|
|
58
|
+
`<label class="ol8-field__label" for="${escapeAttr(id)}">${escapeText(label)}</label>` +
|
|
59
|
+
(required ? `<span class="ol8-field__requirement" aria-hidden="true">*</span>` : '') +
|
|
60
|
+
`</div>`
|
|
61
|
+
: '';
|
|
62
|
+
|
|
63
|
+
const control =
|
|
64
|
+
`<div class="ol8-field__control">` +
|
|
65
|
+
(leadingIcon ? renderIcon(leadingIcon, { size: 18, className: 'ol8-field__leading-icon' }) : '') +
|
|
66
|
+
(prefix ? `<span class="ol8-field__affix" aria-hidden="true">${escapeText(prefix)}</span>` : '') +
|
|
67
|
+
`<input class="ol8-field__input" id="${escapeAttr(id)}" type="${escapeAttr(type)}"` +
|
|
68
|
+
(name ? ` name="${escapeAttr(name)}"` : '') +
|
|
69
|
+
(value !== undefined ? ` value="${escapeAttr(value)}"` : '') +
|
|
70
|
+
(placeholder ? ` placeholder="${escapeAttr(placeholder)}"` : '') +
|
|
71
|
+
(required ? ' required' : '') +
|
|
72
|
+
(disabled ? ' disabled' : '') +
|
|
73
|
+
(readOnly ? ' readonly' : '') +
|
|
74
|
+
(invalid ? ' aria-invalid="true"' : '') +
|
|
75
|
+
(invalid && messageId ? ` aria-errormessage="${messageId}"` : '') +
|
|
76
|
+
// A native maxlength counts UTF-16 units, which would cut an emoji in half.
|
|
77
|
+
// The limit is enforced here instead, in graphemes, per the contract.
|
|
78
|
+
(characterLimit !== undefined ? ` data-ol8-limit="${characterLimit}" data-ol8-limit-behavior="${characterLimitBehavior}"` : '') +
|
|
79
|
+
(describedBy ? ` aria-describedby="${escapeAttr(describedBy)}"` : '') +
|
|
80
|
+
(showLabel ? '' : ` aria-label="${escapeAttr(label)}"`) +
|
|
81
|
+
`>` +
|
|
82
|
+
(suffix ? `<span class="ol8-field__affix" aria-hidden="true">${escapeText(suffix)}</span>` : '') +
|
|
83
|
+
(trailingAction
|
|
84
|
+
? `<button class="ol8-field__trailing-action" type="button"` +
|
|
85
|
+
(disabled ? ' disabled' : '') +
|
|
86
|
+
` aria-label="${escapeAttr(trailingAction.label)}">` +
|
|
87
|
+
renderIcon(trailingAction.icon, { size: 18 }) +
|
|
88
|
+
`</button>`
|
|
89
|
+
: '') +
|
|
90
|
+
`</div>`;
|
|
91
|
+
|
|
92
|
+
// Instruction is its own full width row. Message and counter share the next
|
|
93
|
+
// one, counter at the logical inline end, wrapping below at narrow widths.
|
|
94
|
+
const supporting = (message || counterId)
|
|
95
|
+
? `<div class="ol8-field__supporting">` +
|
|
96
|
+
(message
|
|
97
|
+
? renderFormMessage(message, { tone: validationStatus === 'pending' ? 'pending' : messageTone, icon: messageIcon, id: messageId })
|
|
98
|
+
: '<span class="ol8-field__supporting-spacer"></span>') +
|
|
99
|
+
(counterId
|
|
100
|
+
? `<span class="ol8-field__counter" id="${counterId}">${countGraphemes(value)} / ${characterLimit}</span>`
|
|
101
|
+
: '') +
|
|
102
|
+
`</div>`
|
|
103
|
+
: '';
|
|
104
|
+
|
|
105
|
+
const attrs = [
|
|
106
|
+
`class="ol8-field${className ? ` ${className}` : ''}"`,
|
|
107
|
+
`data-ol8-size="${size}"`,
|
|
108
|
+
`data-ol8-appearance="${appearance}"`,
|
|
109
|
+
material === 'gem' ? 'data-ol8-material="gem"' : '',
|
|
110
|
+
disabled ? 'data-ol8-disabled="true"' : '',
|
|
111
|
+
readOnly ? 'data-ol8-readonly="true"' : '',
|
|
112
|
+
invalid ? 'data-ol8-invalid="true"' : '',
|
|
113
|
+
validationStatus !== 'idle' ? `data-ol8-validation="${validationStatus}"` : '',
|
|
114
|
+
].filter(Boolean).join(' ');
|
|
115
|
+
|
|
116
|
+
return `<div ${attrs}>` +
|
|
117
|
+
labelRow +
|
|
118
|
+
`<div class="ol8-field__control-stack">${control}</div>` +
|
|
119
|
+
(instruction ? `<div class="ol8-field__instruction" id="${instructionId}">${escapeText(instruction)}</div>` : '') +
|
|
120
|
+
supporting +
|
|
121
|
+
`</div>`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Enforces the runtime contract on existing `.ol8-field` markup. Idempotent.
|
|
126
|
+
*
|
|
127
|
+
* - a field must own exactly one native input, since that is what carries
|
|
128
|
+
* type, value, autofill, IME, undo and the platform's text services
|
|
129
|
+
* - the label must point at that input, or it labels nothing
|
|
130
|
+
* - clicking anywhere in the control frame focuses the input, except on the
|
|
131
|
+
* independent trailing action
|
|
132
|
+
* - the counter counts graphemes as the person types, and a hard limit stops
|
|
133
|
+
* accepting input rather than corrupting a cluster
|
|
134
|
+
*/
|
|
135
|
+
export function hydrateTextFields(root = document) {
|
|
136
|
+
const problems = [];
|
|
137
|
+
for (const field of root.querySelectorAll('.ol8-field')) {
|
|
138
|
+
const input = field.querySelector('.ol8-field__input');
|
|
139
|
+
if (!input) { problems.push(field); continue; }
|
|
140
|
+
|
|
141
|
+
if (!field.dataset.ol8Size) field.dataset.ol8Size = 'comfortable';
|
|
142
|
+
if (!field.dataset.ol8Appearance) field.dataset.ol8Appearance = 'outline';
|
|
143
|
+
|
|
144
|
+
const label = field.querySelector('.ol8-field__label');
|
|
145
|
+
if (label && !label.getAttribute('for')) {
|
|
146
|
+
if (!input.id) input.id = nextId();
|
|
147
|
+
label.setAttribute('for', input.id);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const control = field.querySelector('.ol8-field__control');
|
|
151
|
+
if (control && control.dataset.ol8Wired !== 'true') {
|
|
152
|
+
control.dataset.ol8Wired = 'true';
|
|
153
|
+
control.addEventListener('mousedown', (event) => {
|
|
154
|
+
if (event.target === input) return;
|
|
155
|
+
if (event.target.closest('.ol8-field__trailing-action')) return;
|
|
156
|
+
event.preventDefault();
|
|
157
|
+
input.focus();
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const limit = Number(input.dataset.ol8Limit);
|
|
162
|
+
if (Number.isInteger(limit) && input.dataset.ol8Counting !== 'true') {
|
|
163
|
+
input.dataset.ol8Counting = 'true';
|
|
164
|
+
const counter = field.querySelector('.ol8-field__counter');
|
|
165
|
+
const paint = () => {
|
|
166
|
+
if (input.dataset.ol8LimitBehavior === 'hard') {
|
|
167
|
+
const clipped = clipToGraphemes(input.value, limit);
|
|
168
|
+
if (clipped !== input.value) input.value = clipped;
|
|
169
|
+
}
|
|
170
|
+
if (counter) counter.textContent = `${countGraphemes(input.value)} / ${limit}`;
|
|
171
|
+
};
|
|
172
|
+
input.addEventListener('input', paint);
|
|
173
|
+
paint();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (problems.length > 0) {
|
|
177
|
+
console.warn(`[ol8] ${problems.length} .ol8-field element(s) contain no native input`, problems);
|
|
178
|
+
}
|
|
179
|
+
return problems.length;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Counts what a reader would call characters. An emoji built from several code
|
|
184
|
+
* points is one, which is why the contract asks for extended grapheme clusters
|
|
185
|
+
* rather than UTF-16 code units or bytes.
|
|
186
|
+
*/
|
|
187
|
+
export function countGraphemes(value) {
|
|
188
|
+
return segment(value).length;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Keeps the first `limit` graphemes, so a cluster is never cut in half. */
|
|
192
|
+
export function clipToGraphemes(value, limit) {
|
|
193
|
+
const parts = segment(value);
|
|
194
|
+
return parts.length <= limit ? String(value ?? '') : parts.slice(0, limit).join('');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function segment(value) {
|
|
198
|
+
const text = String(value ?? '');
|
|
199
|
+
if (typeof Intl !== 'undefined' && Intl.Segmenter) {
|
|
200
|
+
return [...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(text)].map(s => s.segment);
|
|
201
|
+
}
|
|
202
|
+
return [...text];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Every rule here is one the contract states outright. */
|
|
206
|
+
function assertContract({
|
|
207
|
+
label, size, appearance, material, messageTone, validationStatus,
|
|
208
|
+
characterLimit, characterLimitBehavior, readOnly, invalid,
|
|
209
|
+
}) {
|
|
210
|
+
if (typeof label !== 'string' || label === '') {
|
|
211
|
+
throw new Error('[ol8] Text Field requires a label. A placeholder is never the label.');
|
|
212
|
+
}
|
|
213
|
+
if (!OL8_TEXT_FIELD_SIZES.includes(size)) throw new Error(`[ol8] unknown text field size "${size}"`);
|
|
214
|
+
if (!OL8_TEXT_FIELD_APPEARANCES.includes(appearance)) throw new Error(`[ol8] unknown text field appearance "${appearance}"`);
|
|
215
|
+
if (!OL8_TEXT_FIELD_MATERIALS.includes(material)) throw new Error(`[ol8] unknown text field material "${material}"`);
|
|
216
|
+
if (!OL8_MESSAGE_TONES.includes(messageTone)) {
|
|
217
|
+
throw new Error(`[ol8] unknown message tone "${messageTone}". Pending is a validationStatus, not a tone.`);
|
|
218
|
+
}
|
|
219
|
+
if (!OL8_VALIDATION_STATUSES.includes(validationStatus)) {
|
|
220
|
+
throw new Error(`[ol8] unknown validation status "${validationStatus}"`);
|
|
221
|
+
}
|
|
222
|
+
if (!OL8_CHARACTER_LIMIT_BEHAVIORS.includes(characterLimitBehavior)) {
|
|
223
|
+
throw new Error(`[ol8] unknown character limit behavior "${characterLimitBehavior}"`);
|
|
224
|
+
}
|
|
225
|
+
if (characterLimit !== undefined && (!Number.isInteger(characterLimit) || characterLimit <= 0)) {
|
|
226
|
+
throw new Error('[ol8] characterLimit counts graphemes, so it must be a positive whole number.');
|
|
227
|
+
}
|
|
228
|
+
if (readOnly && invalid) {
|
|
229
|
+
throw new Error('[ol8] a read only field cannot be invalid. If a person cannot correct the value, the problem belongs to system feedback rather than to an editable field error.');
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function escapeAttr(v) { return String(v).replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<'); }
|
|
234
|
+
function escapeText(v) { return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { OL8_TOKEN_SIZES, OL8_TOKEN_MARKS, resolveTokenMark, renderToken } from './token.js';
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Oneli8 · Token (MOLECULE)
|
|
3
|
+
* Figma: Select and Combobox / Molecule / Token 897:2106 (24 variants)
|
|
4
|
+
*
|
|
5
|
+
* The silhouette, sizes and colours all come from the Choice Chip stylesheet,
|
|
6
|
+
* because Figma's Token instantiates the Choice Chip Geometry Owner rather than
|
|
7
|
+
* redrawing it. Every rule here is about the one axis Token adds: the Mark.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/* A token is content, not a control. Only its Remove mark is interactive. */
|
|
11
|
+
.ol8-token.ol8-chip { cursor: default; }
|
|
12
|
+
|
|
13
|
+
/* ---- Remove ---------------------------------------------------------- */
|
|
14
|
+
.ol8-token__remove {
|
|
15
|
+
position: relative; /* anchors the expanded target below */
|
|
16
|
+
padding: 0;
|
|
17
|
+
border: 0;
|
|
18
|
+
background: none;
|
|
19
|
+
color: inherit;
|
|
20
|
+
cursor: pointer;
|
|
21
|
+
/* The ring follows the mark, not the chip: the chip is not the target. */
|
|
22
|
+
border-radius: var(--ol8-shape-radius-full);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/* The mark is drawn at 18 because that is what Figma draws, but the contract
|
|
26
|
+
asks for a target of at least 24 that does not overlap anything. The target
|
|
27
|
+
is expanded with a pseudo element so the drawn size and the reachable size
|
|
28
|
+
can differ without moving the label: it grows 3 into the 9 gap on one side
|
|
29
|
+
and into the rail inset on the other, so it still touches neither. */
|
|
30
|
+
.ol8-token__remove::before {
|
|
31
|
+
content: "";
|
|
32
|
+
position: absolute;
|
|
33
|
+
inline-size: var(--ol8-component-choice-chip-mark-target);
|
|
34
|
+
block-size: var(--ol8-component-choice-chip-mark-target);
|
|
35
|
+
transform: translate(-50%, -50%);
|
|
36
|
+
inset-block-start: 50%;
|
|
37
|
+
inset-inline-start: 50%;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.ol8-token__remove:focus-visible {
|
|
41
|
+
outline: var(--ol8-focus-ring-innerwidth) solid var(--ol8-color-focus-inner);
|
|
42
|
+
outline-offset: var(--ol8-focus-ring-offset);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* ---- Mark size parity ------------------------------------------------
|
|
46
|
+
The two marks have to read as one size, and they did not. The Icon Library's
|
|
47
|
+
cross spans 12 x 12 in the canonical 24 space while the check spans
|
|
48
|
+
10.2 x 6.9, so drawn at one frame size the cross was fifteen percent wider
|
|
49
|
+
and well over half again as tall, and it swamped the check beside it.
|
|
50
|
+
|
|
51
|
+
Figma does not draw it that way: its Remove mark is an Icon Frame instance at
|
|
52
|
+
18, so the cross comes out at 9 x 9 there, a little narrower than the check
|
|
53
|
+
and balanced against it. The oversized cross was this stylesheet rendering
|
|
54
|
+
the library source at full size, not a decision in the design.
|
|
55
|
+
|
|
56
|
+
So the cross is scaled to the 9 span Figma draws. The stroke does not follow
|
|
57
|
+
it: it is a non scaling stroke, so it stays the governed 1.8 that every glyph
|
|
58
|
+
in the system uses, which is also what Figma keeps. */
|
|
59
|
+
.ol8-token__remove > svg > * {
|
|
60
|
+
transform: scale(0.75); /* 9 / 12, the span Figma draws */
|
|
61
|
+
transform-box: fill-box;
|
|
62
|
+
transform-origin: center;
|
|
63
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token (MOLECULE) — a committed value. It delegates every bit of geometry to
|
|
3
|
+
* the Choice Chip owner, exactly as the Figma component does, and adds only the
|
|
4
|
+
* Mark axis. A token is content with an optional Remove button, not a control.
|
|
5
|
+
*/
|
|
6
|
+
import type { Ol8ChipSize } from '../choice-chip/choice-chip.js';
|
|
7
|
+
|
|
8
|
+
export type Ol8TokenSize = Ol8ChipSize;
|
|
9
|
+
/** Figma draws the last three; `auto` resolves to one of them. */
|
|
10
|
+
export type Ol8TokenMark = 'auto' | 'none' | 'check' | 'remove';
|
|
11
|
+
|
|
12
|
+
export declare const OL8_TOKEN_SIZES: readonly Ol8TokenSize[];
|
|
13
|
+
export declare const OL8_TOKEN_MARKS: readonly Ol8TokenMark[];
|
|
14
|
+
|
|
15
|
+
/** Remove for a removable value, Check for a selected one, None otherwise. */
|
|
16
|
+
export declare function resolveTokenMark(
|
|
17
|
+
mark: Ol8TokenMark,
|
|
18
|
+
state?: { removable?: boolean; selected?: boolean },
|
|
19
|
+
): Exclude<Ol8TokenMark, 'auto'>;
|
|
20
|
+
|
|
21
|
+
export interface Ol8TokenOptions {
|
|
22
|
+
size?: Ol8TokenSize;
|
|
23
|
+
selected?: boolean;
|
|
24
|
+
mark?: Ol8TokenMark;
|
|
25
|
+
/** A non removable token may not carry a Remove mark. */
|
|
26
|
+
removable?: boolean;
|
|
27
|
+
/** Overrides the default "Remove {label}" accessible name. */
|
|
28
|
+
removeLabel?: string;
|
|
29
|
+
/** False makes the Remove button reachable by arrow key rather than Tab. */
|
|
30
|
+
focusable?: boolean;
|
|
31
|
+
id?: string;
|
|
32
|
+
className?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export declare function renderToken(label: string, options?: Ol8TokenOptions): string;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Oneli8 · Token (MOLECULE) — a committed value.
|
|
3
|
+
*
|
|
4
|
+
* Figma: Select and Combobox / Molecule / Token 897:2106 (24 variants,
|
|
5
|
+
* Size x Selection x Mark).
|
|
6
|
+
*
|
|
7
|
+
* From the Figma description:
|
|
8
|
+
* "Compact/Standard/Comfortable/Large delegate all 30/36/42/42px geometry to
|
|
9
|
+
* the Soft Hexagon Choice Chip owner. Selection and None/Check/Remove mark
|
|
10
|
+
* contracts remain independent; governed Check and Close atoms are reused
|
|
11
|
+
* instead of duplicated."
|
|
12
|
+
*
|
|
13
|
+
* So this draws no silhouette of its own. It instantiates the Choice Chip's
|
|
14
|
+
* geometry exactly as the Figma component instantiates the Geometry Owner, and
|
|
15
|
+
* adds the one thing that is genuinely its own: the Mark axis.
|
|
16
|
+
*
|
|
17
|
+
* A Token is not a Choice Chip. A chip is a checkbox a person toggles; a token
|
|
18
|
+
* is a value that has already been committed, so it is ordinary content with an
|
|
19
|
+
* optional Remove button rather than a form control.
|
|
20
|
+
*
|
|
21
|
+
* MARK
|
|
22
|
+
* Figma's axis is the concrete None / Check / Remove. The contract adds `auto`
|
|
23
|
+
* as the public default, which resolves rather than draws: "Remove for
|
|
24
|
+
* removable committed values, Check for selected Choice Chips, and None for
|
|
25
|
+
* unselected or informational chips." A variant cannot be `auto`, which is why
|
|
26
|
+
* Figma has no such option and code does.
|
|
27
|
+
*/
|
|
28
|
+
import { renderIconSvg } from '../../atoms/icon/icon.js';
|
|
29
|
+
import { renderChipShape, escapeAttr, OL8_CHIP_SIZES } from '../choice-chip/choice-chip.js';
|
|
30
|
+
|
|
31
|
+
export const OL8_TOKEN_SIZES = OL8_CHIP_SIZES;
|
|
32
|
+
/** Figma draws the last three. `auto` resolves to one of them. */
|
|
33
|
+
export const OL8_TOKEN_MARKS = ['auto', 'none', 'check', 'remove'];
|
|
34
|
+
|
|
35
|
+
/** The contract's resolution, stated once so both packages agree. */
|
|
36
|
+
export function resolveTokenMark(mark, { removable = true, selected = false } = {}) {
|
|
37
|
+
if (mark !== 'auto') return mark;
|
|
38
|
+
if (removable) return 'remove';
|
|
39
|
+
return selected ? 'check' : 'none';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {string} label
|
|
44
|
+
* @param {{size?:string,selected?:boolean,mark?:string,removable?:boolean,
|
|
45
|
+
* removeLabel?:string,id?:string,className?:string}} [options]
|
|
46
|
+
*/
|
|
47
|
+
export function renderToken(label, options = {}) {
|
|
48
|
+
const {
|
|
49
|
+
size = 'standard', selected = false, mark = 'auto', removable = true,
|
|
50
|
+
removeLabel, focusable = true, id, className,
|
|
51
|
+
} = options;
|
|
52
|
+
|
|
53
|
+
if (!OL8_TOKEN_SIZES.includes(size)) throw new Error(`[ol8] unknown token size "${size}"`);
|
|
54
|
+
if (!OL8_TOKEN_MARKS.includes(mark)) throw new Error(`[ol8] unknown token mark "${mark}"`);
|
|
55
|
+
if (!label || !String(label).trim()) throw new Error('[ol8] Token requires a label');
|
|
56
|
+
if (mark === 'remove' && !removable) {
|
|
57
|
+
throw new Error('[ol8] mark "remove" offers a removal path a non removable token does not have');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const uid = id ?? `ol8-token-${Math.random().toString(36).slice(2, 9)}`;
|
|
61
|
+
const resolved = resolveTokenMark(mark, { removable, selected });
|
|
62
|
+
|
|
63
|
+
// The Remove mark is the only interactive part, so it is a real button with
|
|
64
|
+
// its own accessible name. It follows the label, which is the anatomy the
|
|
65
|
+
// contract writes down, and the label stays the token's content: the complete
|
|
66
|
+
// value must remain readable, never shortened or overlaid by its mark.
|
|
67
|
+
const markup = resolved === 'remove'
|
|
68
|
+
? `<button type="button" class="ol8-chip__mark ol8-token__remove" ` +
|
|
69
|
+
(focusable ? '' : 'tabindex="-1" ') +
|
|
70
|
+
`aria-label="${escapeAttr(removeLabel ?? `Remove ${label}`)}">${renderIconSvg('close')}</button>`
|
|
71
|
+
: resolved === 'check'
|
|
72
|
+
? `<span class="ol8-chip__mark" aria-hidden="true">${renderIconSvg('check')}</span>`
|
|
73
|
+
: '';
|
|
74
|
+
|
|
75
|
+
const attrs = [
|
|
76
|
+
`class="ol8-token ol8-chip${className ? ` ${className}` : ''}"`,
|
|
77
|
+
`data-ol8-size="${size}"`,
|
|
78
|
+
`data-ol8-selection="${selected ? 'selected' : 'inactive'}"`,
|
|
79
|
+
`data-ol8-mark="${resolved}"`,
|
|
80
|
+
].join(' ');
|
|
81
|
+
|
|
82
|
+
return `<span ${attrs}>${renderChipShape(uid, label, markup)}</span>`;
|
|
83
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Oneli8 · Choice Group (ORGANISM)
|
|
3
|
+
* Figma: Documentation / Organism / Choice Group
|
|
4
|
+
* Checkbox Hierarchy 273:603 Aggregate = None | Some | All
|
|
5
|
+
* Radio Group 859:5180 Behavior = Safe Default | No Default |
|
|
6
|
+
* Horizontal | Invalid
|
|
7
|
+
*
|
|
8
|
+
* First organism in the system, and the first tier allowed to hold logic:
|
|
9
|
+
* the checkbox group computes its parent from its children, and the radio
|
|
10
|
+
* group owns group-level validation.
|
|
11
|
+
*
|
|
12
|
+
* From Figma (273:603): "Aggregate None/Some/All is computed from nested
|
|
13
|
+
* Checkbox molecules; MIXED IS NEVER A THIRD PREFERENCE. Runtime recursion,
|
|
14
|
+
* parent-child synchronization, group naming, required behavior, reset, and
|
|
15
|
+
* announcements live in code."
|
|
16
|
+
*
|
|
17
|
+
* So Aggregate is an OUTPUT, never an input. Nothing may author it.
|
|
18
|
+
*
|
|
19
|
+
* The two shells differ, deliberately:
|
|
20
|
+
* checkbox bordered, radius-container (24px), legend Title/Small
|
|
21
|
+
* radio unbordered, radius-standard (12px), legend Body/Medium
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
.ol8-choice-group {
|
|
25
|
+
box-sizing: border-box;
|
|
26
|
+
display: flex;
|
|
27
|
+
flex-direction: column;
|
|
28
|
+
gap: var(--ol8-spacing-stack-related);
|
|
29
|
+
padding: var(--ol8-spacing-inset-standard);
|
|
30
|
+
background: var(--ol8-color-surface-raised);
|
|
31
|
+
border: 0;
|
|
32
|
+
border-radius: var(--ol8-shape-radius-standard);
|
|
33
|
+
font-family: var(--ol8-font-family-functional);
|
|
34
|
+
font-variation-settings: "ARRR" var(--ol8-font-axis-arrr-baseline);
|
|
35
|
+
min-inline-size: 0; /* fieldset defaults to min-content; this lets it shrink */
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.ol8-choice-group--checkbox {
|
|
39
|
+
border: 1px solid var(--ol8-color-border-subtle);
|
|
40
|
+
border-radius: var(--ol8-shape-radius-container);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* <legend> is taken out of flow by default, so it is laid out as a normal
|
|
44
|
+
flex child instead — the group label is a real legend for assistive tech. */
|
|
45
|
+
.ol8-choice-group__legend {
|
|
46
|
+
display: block;
|
|
47
|
+
float: none;
|
|
48
|
+
padding: 0;
|
|
49
|
+
inline-size: 100%;
|
|
50
|
+
color: var(--ol8-color-text-primary);
|
|
51
|
+
}
|
|
52
|
+
.ol8-choice-group--checkbox > .ol8-choice-group__legend {
|
|
53
|
+
font-size: var(--ol8-font-size-021);
|
|
54
|
+
line-height: var(--ol8-font-line-030);
|
|
55
|
+
font-weight: var(--ol8-font-weight-semibold);
|
|
56
|
+
}
|
|
57
|
+
.ol8-choice-group--radio > .ol8-choice-group__legend {
|
|
58
|
+
font-size: var(--ol8-font-size-016);
|
|
59
|
+
line-height: var(--ol8-font-line-024);
|
|
60
|
+
font-weight: var(--ol8-font-weight-regular);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.ol8-choice-group__instruction {
|
|
64
|
+
font-size: var(--ol8-font-size-012);
|
|
65
|
+
line-height: var(--ol8-font-line-018);
|
|
66
|
+
letter-spacing: 0.12px;
|
|
67
|
+
color: var(--ol8-color-text-secondary);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.ol8-choice-group__options { display: flex; flex-direction: column; }
|
|
71
|
+
.ol8-choice-group[data-ol8-orientation="horizontal"] > .ol8-choice-group__options {
|
|
72
|
+
flex-direction: row;
|
|
73
|
+
gap: var(--ol8-spacing-stack-related);
|
|
74
|
+
flex-wrap: wrap;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* Children of a hierarchy sit under their parent, indented one 24px step. */
|
|
78
|
+
.ol8-choice-group__children {
|
|
79
|
+
display: flex;
|
|
80
|
+
flex-direction: column;
|
|
81
|
+
padding-inline-start: var(--ol8-dimension-scale-024);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* Group-level validation message. */
|
|
85
|
+
.ol8-choice-group__message {
|
|
86
|
+
font-size: var(--ol8-font-size-012);
|
|
87
|
+
line-height: var(--ol8-font-line-018);
|
|
88
|
+
letter-spacing: 0.12px;
|
|
89
|
+
color: var(--ol8-color-text-secondary);
|
|
90
|
+
}
|
|
91
|
+
.ol8-choice-group[data-ol8-invalid="true"] > .ol8-choice-group__message {
|
|
92
|
+
color: var(--ol8-color-feedbackcritical-inlinecontent);
|
|
93
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Choice Group (ORGANISM) — headless behavior. Aggregate None/Some/All is
|
|
3
|
+
* computed from nested checkboxes; Mixed is never a third preference. The radio
|
|
4
|
+
* group needs almost nothing: native radios already give roving focus, single
|
|
5
|
+
* selection and form submission.
|
|
6
|
+
*/
|
|
7
|
+
import type { Ol8ChoiceSize } from '../../molecules/choice-item/choice-item.js';
|
|
8
|
+
|
|
9
|
+
export type Ol8Aggregate = 'none' | 'some' | 'all';
|
|
10
|
+
export declare const OL8_AGGREGATE: readonly Ol8Aggregate[];
|
|
11
|
+
|
|
12
|
+
/** Disabled children are excluded from the tally. */
|
|
13
|
+
export declare function computeAggregate(
|
|
14
|
+
children: Iterable<HTMLInputElement> | ArrayLike<HTMLInputElement>,
|
|
15
|
+
): Ol8Aggregate;
|
|
16
|
+
|
|
17
|
+
interface Ol8ChoiceGroupShell {
|
|
18
|
+
legend: string;
|
|
19
|
+
instruction?: string;
|
|
20
|
+
message?: string;
|
|
21
|
+
invalid?: boolean;
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
size?: Ol8ChoiceSize;
|
|
24
|
+
material?: 'regular' | 'gem';
|
|
25
|
+
id?: string;
|
|
26
|
+
className?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface Ol8RadioOption {
|
|
30
|
+
label: string;
|
|
31
|
+
value: string;
|
|
32
|
+
description?: string;
|
|
33
|
+
disabled?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface Ol8RadioGroupConfig extends Ol8ChoiceGroupShell {
|
|
37
|
+
/** Required — it is what makes the radios a group. */
|
|
38
|
+
name: string;
|
|
39
|
+
options: Ol8RadioOption[];
|
|
40
|
+
/** The selected option's value. Omit for no default. */
|
|
41
|
+
value?: string;
|
|
42
|
+
orientation?: 'vertical' | 'horizontal';
|
|
43
|
+
required?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface Ol8CheckboxOption {
|
|
47
|
+
label: string;
|
|
48
|
+
value: string;
|
|
49
|
+
/** Checkboxes are independent, so each carries its own state. */
|
|
50
|
+
checked?: boolean;
|
|
51
|
+
description?: string;
|
|
52
|
+
disabled?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface Ol8CheckboxGroupConfig extends Ol8ChoiceGroupShell {
|
|
56
|
+
/** Required — the hierarchy's parent row. */
|
|
57
|
+
parentLabel: string;
|
|
58
|
+
options: Ol8CheckboxOption[];
|
|
59
|
+
/** Shared input name for the children. Optional: checkboxes need no group name. */
|
|
60
|
+
name?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Native semantics do the work; this only builds the shell.
|
|
65
|
+
* @throws if legend, name or options are missing.
|
|
66
|
+
*/
|
|
67
|
+
export declare function renderRadioGroup(config: Ol8RadioGroupConfig): string;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Checkbox hierarchy: one parent whose state is computed from its children. The
|
|
71
|
+
* parent renders unchecked; hydration derives its real state.
|
|
72
|
+
* @throws if legend, parentLabel or options are missing.
|
|
73
|
+
*/
|
|
74
|
+
export declare function renderCheckboxGroup(config: Ol8CheckboxGroupConfig): string;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Wires every checkbox hierarchy under `root`. Idempotent.
|
|
78
|
+
* Radio groups need no wiring — the browser already owns their behaviour.
|
|
79
|
+
*/
|
|
80
|
+
export declare function hydrateChoiceGroups(root?: ParentNode): void;
|