@oneli8/core 1.0.0-beta.5 → 1.0.0-beta.7
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/README.md +19 -7
- package/ai-context.json +170 -3
- package/package.json +18 -3
- package/src/atoms/index.d.ts +1 -0
- package/src/atoms/index.js +1 -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/selection-indicator.css +13 -4
- package/src/materials/gem/gem.css +52 -5
- 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/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/index.d.ts +6 -0
- package/src/molecules/index.js +6 -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/selection-option.js +4 -4
- 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-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 +7 -0
- package/src/organisms/index.js +7 -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 +18 -2
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Oneli8 · Segmented Control — headless behavior.
|
|
3
|
+
*
|
|
4
|
+
* Figma (698:4792): "Behavior is a consuming contract: single = Radio Group,
|
|
5
|
+
* multi = independent Toggle Buttons, momentary = grouped actions."
|
|
6
|
+
*
|
|
7
|
+
* The contract says to declare exactly one behaviour, so this module refuses to
|
|
8
|
+
* guess: behaviour is required, and the shape of `selected` must match it.
|
|
9
|
+
* "Presentation never changes behavior", so nothing below reads presentation.
|
|
10
|
+
*/
|
|
11
|
+
import { renderNavigationItem } from '../../molecules/navigation-item/index.js';
|
|
12
|
+
import { resolveNavigationKey, isRtl } from '../navigation-keys.js';
|
|
13
|
+
|
|
14
|
+
export const OL8_SEGMENT_BEHAVIORS = ['single', 'multi', 'momentary'];
|
|
15
|
+
export const OL8_SEGMENT_PRESENTATIONS = [
|
|
16
|
+
'inset-fill', 'line-indicator', 'outlined-selection', 'soft-pill', 'icon-only', 'stacked-label',
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {{
|
|
21
|
+
* label:string, behavior:string,
|
|
22
|
+
* items:Array<{id:string,label?:string,icon?:string,badge?:any,disabled?:boolean,ariaLabel?:string}>,
|
|
23
|
+
* selected?:string|string[], presentation?:string, size?:string, material?:string, className?:string,
|
|
24
|
+
* }} options
|
|
25
|
+
*/
|
|
26
|
+
export function renderSegmentedControl(options) {
|
|
27
|
+
const {
|
|
28
|
+
label, behavior, items = [], selected,
|
|
29
|
+
presentation = 'inset-fill', size = 'standard', material = 'regular', className,
|
|
30
|
+
} = options ?? {};
|
|
31
|
+
|
|
32
|
+
assertSegmentedControl({ label, behavior, items, selected, presentation });
|
|
33
|
+
|
|
34
|
+
const isSelected = (id) => {
|
|
35
|
+
if (behavior === 'momentary') return false;
|
|
36
|
+
if (behavior === 'multi') return Array.isArray(selected) && selected.includes(id);
|
|
37
|
+
return selected === id;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Icon only and stacked label are presentations, so they change how the item
|
|
41
|
+
// lays out its content, never what the item means.
|
|
42
|
+
const layout = presentation === 'stacked-label' ? 'stacked' : 'inline';
|
|
43
|
+
const iconOnly = presentation === 'icon-only';
|
|
44
|
+
|
|
45
|
+
const firstEnabled = items.find(i => !i.disabled);
|
|
46
|
+
const focusStop = behavior === 'single'
|
|
47
|
+
? (items.find(i => isSelected(i.id) && !i.disabled) ?? firstEnabled)
|
|
48
|
+
: null;
|
|
49
|
+
|
|
50
|
+
const segments = items.map(item => renderNavigationItem({
|
|
51
|
+
kind: 'segment',
|
|
52
|
+
label: iconOnly ? undefined : item.label,
|
|
53
|
+
icon: item.icon,
|
|
54
|
+
badge: item.badge,
|
|
55
|
+
layout,
|
|
56
|
+
selected: isSelected(item.id),
|
|
57
|
+
disabled: item.disabled,
|
|
58
|
+
ariaLabel: item.ariaLabel ?? (iconOnly ? item.label : undefined),
|
|
59
|
+
extraAttrs: `data-ol8-size="${size}" data-ol8-value="${escapeAttr(item.id)}"`
|
|
60
|
+
// Single selection keeps one focus stop, the way a radio group does.
|
|
61
|
+
+ (focusStop ? ` tabindex="${item.id === focusStop.id ? 0 : -1}"` : ''),
|
|
62
|
+
})).join('');
|
|
63
|
+
|
|
64
|
+
const attrs = [
|
|
65
|
+
`class="ol8-segmented${className ? ` ${className}` : ''}"`,
|
|
66
|
+
behavior === 'single' ? 'role="radiogroup"' : 'role="group"',
|
|
67
|
+
`aria-label="${escapeAttr(label)}"`,
|
|
68
|
+
`data-ol8-presentation="${presentation}"`,
|
|
69
|
+
`data-ol8-behavior="${behavior}"`,
|
|
70
|
+
`data-ol8-size="${size}"`,
|
|
71
|
+
material === 'gem' ? 'data-ol8-material="gem"' : '',
|
|
72
|
+
].filter(Boolean).join(' ');
|
|
73
|
+
|
|
74
|
+
return `<div ${attrs}>${segments}</div>`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Wires an existing `.ol8-segmented` group. Idempotent. */
|
|
78
|
+
export function hydrateSegmentedControls(root = document) {
|
|
79
|
+
let count = 0;
|
|
80
|
+
for (const group of root.querySelectorAll('.ol8-segmented')) {
|
|
81
|
+
if (group.dataset.ol8Wired === 'true') continue;
|
|
82
|
+
group.dataset.ol8Wired = 'true';
|
|
83
|
+
count += 1;
|
|
84
|
+
|
|
85
|
+
const behavior = group.dataset.ol8Behavior;
|
|
86
|
+
const segmentsOf = () => [...group.querySelectorAll('.ol8-nav-item--segment')];
|
|
87
|
+
|
|
88
|
+
const announce = (segment) => segment.dispatchEvent(new CustomEvent('ol8:segmentchange', {
|
|
89
|
+
bubbles: true,
|
|
90
|
+
detail: {
|
|
91
|
+
value: segment.dataset.ol8Value,
|
|
92
|
+
selected: segmentsOf().filter(s => s.getAttribute('aria-pressed') === 'true').map(s => s.dataset.ol8Value),
|
|
93
|
+
},
|
|
94
|
+
}));
|
|
95
|
+
|
|
96
|
+
group.addEventListener('click', (event) => {
|
|
97
|
+
const segment = event.target.closest('.ol8-nav-item--segment');
|
|
98
|
+
if (!segment || segment.disabled) return;
|
|
99
|
+
|
|
100
|
+
if (behavior === 'momentary') { announce(segment); return; }
|
|
101
|
+
if (behavior === 'multi') {
|
|
102
|
+
segment.setAttribute('aria-pressed', String(segment.getAttribute('aria-pressed') !== 'true'));
|
|
103
|
+
announce(segment);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
for (const other of segmentsOf()) {
|
|
107
|
+
const on = other === segment;
|
|
108
|
+
other.setAttribute('aria-pressed', String(on));
|
|
109
|
+
other.tabIndex = on ? 0 : -1;
|
|
110
|
+
}
|
|
111
|
+
announce(segment);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Only single selection roves, because that is the only behaviour where the
|
|
115
|
+
// group holds one value. Multi and momentary are ordinary buttons in a row.
|
|
116
|
+
if (behavior !== 'single') continue;
|
|
117
|
+
group.addEventListener('keydown', (event) => {
|
|
118
|
+
const segments = segmentsOf();
|
|
119
|
+
const activeIndex = segments.indexOf(document.activeElement);
|
|
120
|
+
if (activeIndex === -1) return;
|
|
121
|
+
const next = resolveNavigationKey(event, segments.map(s => ({ disabled: s.disabled })), activeIndex, {
|
|
122
|
+
orientation: 'horizontal', rtl: isRtl(group),
|
|
123
|
+
});
|
|
124
|
+
if (next === -1 || next === activeIndex) return;
|
|
125
|
+
event.preventDefault();
|
|
126
|
+
segments[next].focus();
|
|
127
|
+
segments[next].click();
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return count;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function assertSegmentedControl({ label, behavior, items, selected, presentation }) {
|
|
134
|
+
if (typeof label !== 'string' || label === '') {
|
|
135
|
+
throw new Error('[ol8] a segmented control requires an accessible name.');
|
|
136
|
+
}
|
|
137
|
+
if (!OL8_SEGMENT_BEHAVIORS.includes(behavior)) {
|
|
138
|
+
throw new Error('[ol8] a segmented control must declare exactly one behavior: single, multi or momentary.');
|
|
139
|
+
}
|
|
140
|
+
if (!OL8_SEGMENT_PRESENTATIONS.includes(presentation)) {
|
|
141
|
+
throw new Error(`[ol8] unknown segmented control presentation "${presentation}"`);
|
|
142
|
+
}
|
|
143
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
144
|
+
throw new Error('[ol8] a segmented control needs at least one segment.');
|
|
145
|
+
}
|
|
146
|
+
if (behavior === 'multi' && selected !== undefined && !Array.isArray(selected)) {
|
|
147
|
+
throw new Error('[ol8] multi selection holds an array of values.');
|
|
148
|
+
}
|
|
149
|
+
if (behavior === 'single' && Array.isArray(selected)) {
|
|
150
|
+
throw new Error('[ol8] single selection holds one value, not an array.');
|
|
151
|
+
}
|
|
152
|
+
if (behavior === 'momentary' && selected !== undefined) {
|
|
153
|
+
throw new Error('[ol8] a momentary control invokes an action and stores no selected state.');
|
|
154
|
+
}
|
|
155
|
+
const seen = new Set();
|
|
156
|
+
for (const item of items) {
|
|
157
|
+
if (!item.id) throw new Error('[ol8] every segment needs an id.');
|
|
158
|
+
if (seen.has(item.id)) throw new Error(`[ol8] two segments share the id "${item.id}"`);
|
|
159
|
+
seen.add(item.id);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function escapeAttr(v) { return String(v).replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<'); }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { renderSelectionPopup, OL8_POPUP_WIDTHS, OL8_POPUP_SIZES, OL8_POPUP_STATUSES } from './selection-popup.js';
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Oneli8 · Selection Popup (ORGANISM)
|
|
3
|
+
* Figma: Building Blocks / Selection Popup 574:534 — Width, Size.
|
|
4
|
+
*
|
|
5
|
+
* The rail is a div with role="listbox" rather than a list element, because a
|
|
6
|
+
* list carries a user agent margin that a component may not zero here, and the
|
|
7
|
+
* role already carries every semantic the list element would have added.
|
|
8
|
+
*
|
|
9
|
+
* Read from Figma: a raised floating surface at the standard radius with a
|
|
10
|
+
* default border, padded by the minimal step inline and the related step
|
|
11
|
+
* block, holding one Option Rail. "Width and Size govern layout only; content
|
|
12
|
+
* must be canonical Selection Option instances."
|
|
13
|
+
*
|
|
14
|
+
* The maximum block is a suggestion, not a cage: the region scrolls rather than
|
|
15
|
+
* clipping, because an option a person cannot reach is an option that does not
|
|
16
|
+
* exist.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
.ol8-popup {
|
|
20
|
+
box-sizing: border-box;
|
|
21
|
+
padding-block: var(--ol8-component-selectionpopup-paddingblock);
|
|
22
|
+
padding-inline: var(--ol8-component-selectionpopup-paddinginline);
|
|
23
|
+
border: var(--ol8-border-width-hairline) solid var(--ol8-color-border-default);
|
|
24
|
+
border-radius: var(--ol8-shape-radius-standard);
|
|
25
|
+
background: var(--ol8-color-surface-raised);
|
|
26
|
+
box-shadow: var(--ol8-elevation-surface-floating);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.ol8-popup__rail {
|
|
30
|
+
display: flex;
|
|
31
|
+
flex-direction: column;
|
|
32
|
+
gap: var(--ol8-component-selectionpopup-gap);
|
|
33
|
+
padding-inline: var(--ol8-component-selectionpopup-railinsetinline);
|
|
34
|
+
overflow-y: auto;
|
|
35
|
+
overscroll-behavior: contain;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* ---- Size ------------------------------------------------------------- */
|
|
39
|
+
.ol8-popup[data-ol8-size="standard"] .ol8-popup__rail {
|
|
40
|
+
max-block-size: var(--ol8-component-selectionpopup-maximumblock-standard);
|
|
41
|
+
}
|
|
42
|
+
.ol8-popup[data-ol8-size="large"] .ol8-popup__rail {
|
|
43
|
+
max-block-size: var(--ol8-component-selectionpopup-maximumblock-large);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* ---- Width · layout only, never meaning -------------------------------
|
|
47
|
+
Content hugs, Anchor matches the control it belongs to, and Wide may exceed
|
|
48
|
+
it when the options are longer than the field. */
|
|
49
|
+
.ol8-popup[data-ol8-width="content"] { inline-size: max-content; }
|
|
50
|
+
.ol8-popup[data-ol8-width="anchor"] { inline-size: 100%; }
|
|
51
|
+
.ol8-popup[data-ol8-width="wide"] { inline-size: max-content; min-inline-size: 100%; }
|
|
52
|
+
|
|
53
|
+
/* ---- Group labels ------------------------------------------------------ */
|
|
54
|
+
.ol8-popup__group-label {
|
|
55
|
+
padding-block: var(--ol8-component-selectionpopup-gap);
|
|
56
|
+
padding-inline: var(--ol8-component-selectionoption-inset-standard);
|
|
57
|
+
color: var(--ol8-color-text-secondary);
|
|
58
|
+
font-family: var(--ol8-font-family-functional);
|
|
59
|
+
font-size: var(--ol8-font-size-012);
|
|
60
|
+
font-weight: var(--ol8-font-weight-semibold);
|
|
61
|
+
line-height: var(--ol8-font-line-018);
|
|
62
|
+
letter-spacing: var(--ol8-font-tracking-open);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* ---- Status ------------------------------------------------------------
|
|
66
|
+
"No results is never represented by a selectable option", so a status is a
|
|
67
|
+
plain region, never an option, and it can never be chosen by mistake. */
|
|
68
|
+
.ol8-popup__status {
|
|
69
|
+
display: flex;
|
|
70
|
+
align-items: center;
|
|
71
|
+
gap: var(--ol8-component-textfield-gap-status);
|
|
72
|
+
padding-block: var(--ol8-component-selectionpopup-paddingblock);
|
|
73
|
+
padding-inline: var(--ol8-component-selectionoption-inset-standard);
|
|
74
|
+
color: var(--ol8-color-text-secondary);
|
|
75
|
+
font-family: var(--ol8-font-family-functional);
|
|
76
|
+
font-size: var(--ol8-font-size-012);
|
|
77
|
+
font-weight: var(--ol8-font-weight-regular);
|
|
78
|
+
line-height: var(--ol8-font-line-018);
|
|
79
|
+
}
|
|
80
|
+
.ol8-popup__status[data-ol8-status="error"] { color: var(--ol8-color-feedbackcritical-inlinecontent); }
|
|
81
|
+
|
|
82
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
83
|
+
.ol8-popup__status[data-ol8-status="loading"] .ol8-icon > svg {
|
|
84
|
+
animation: ol8-spin 900ms linear infinite;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@media (forced-colors: active) {
|
|
89
|
+
.ol8-popup { border-color: CanvasText; box-shadow: none; }
|
|
90
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Oneli8 · Selection Popup — headless behavior.
|
|
3
|
+
*
|
|
4
|
+
* Figma (574:534): "Width and Size govern layout only; content must be
|
|
5
|
+
* canonical Selection Option instances. Runtime listbox naming, ownership,
|
|
6
|
+
* scrolling, active descendant, grouped results, loading/empty/error statuses
|
|
7
|
+
* and announcements live in code."
|
|
8
|
+
*
|
|
9
|
+
* So this module owns exactly those: the listbox and its name, the grouping,
|
|
10
|
+
* the four statuses, and the rule that a status is never an option.
|
|
11
|
+
*/
|
|
12
|
+
import { renderSelectionOption } from '../../molecules/selection-option/index.js';
|
|
13
|
+
import { renderIcon } from '../../atoms/icon/index.js';
|
|
14
|
+
|
|
15
|
+
export const OL8_POPUP_WIDTHS = ['content', 'anchor', 'wide'];
|
|
16
|
+
export const OL8_POPUP_SIZES = ['standard', 'large'];
|
|
17
|
+
|
|
18
|
+
/** Empty query, no matches, loading, and retrieval failure are distinct conditions. */
|
|
19
|
+
export const OL8_POPUP_STATUSES = ['none', 'loading', 'empty', 'error'];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {{
|
|
23
|
+
* id:string, label:string,
|
|
24
|
+
* options?:Array<{value:string,label:string,description?:string,leadingIcon?:string,disabled?:boolean,group?:string}>,
|
|
25
|
+
* selected?:string|string[], active?:string, width?:string, size?:string,
|
|
26
|
+
* status?:string, statusText?:string, multiple?:boolean, className?:string,
|
|
27
|
+
* }} options
|
|
28
|
+
*/
|
|
29
|
+
export function renderSelectionPopup(options) {
|
|
30
|
+
const {
|
|
31
|
+
id, label, options: items = [], selected, active,
|
|
32
|
+
width = 'anchor', size = 'standard', status = 'none', statusText,
|
|
33
|
+
multiple = false, className,
|
|
34
|
+
} = options ?? {};
|
|
35
|
+
|
|
36
|
+
if (!id) throw new Error('[ol8] a selection popup needs an id, because the combobox that owns it points at it.');
|
|
37
|
+
if (typeof label !== 'string' || label === '') {
|
|
38
|
+
throw new Error('[ol8] a listbox requires an accessible name, distinct from the value it holds.');
|
|
39
|
+
}
|
|
40
|
+
if (!OL8_POPUP_WIDTHS.includes(width)) throw new Error(`[ol8] unknown popup width "${width}"`);
|
|
41
|
+
if (!OL8_POPUP_SIZES.includes(size)) throw new Error(`[ol8] unknown popup size "${size}"`);
|
|
42
|
+
if (!OL8_POPUP_STATUSES.includes(status)) throw new Error(`[ol8] unknown popup status "${status}"`);
|
|
43
|
+
if (status !== 'none' && !statusText) {
|
|
44
|
+
throw new Error('[ol8] a popup status needs words. A spinner alone tells a screen reader nothing.');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const isSelected = (value) => Array.isArray(selected) ? selected.includes(value) : selected === value;
|
|
48
|
+
|
|
49
|
+
// Grouped results keep their source order; grouping never reorders matches.
|
|
50
|
+
const groups = [];
|
|
51
|
+
for (const item of items) {
|
|
52
|
+
const name = item.group ?? null;
|
|
53
|
+
const last = groups[groups.length - 1];
|
|
54
|
+
if (last && last.name === name) last.items.push(item);
|
|
55
|
+
else groups.push({ name, items: [item] });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const rail = groups.map((group, index) => {
|
|
59
|
+
const groupId = group.name ? `${id}-group-${index}` : null;
|
|
60
|
+
const rendered = group.items.map(item => renderSelectionOption(item.label, {
|
|
61
|
+
value: item.value,
|
|
62
|
+
size,
|
|
63
|
+
selected: isSelected(item.value),
|
|
64
|
+
active: item.value === active,
|
|
65
|
+
disabled: item.disabled,
|
|
66
|
+
description: item.description,
|
|
67
|
+
leadingIcon: item.leadingIcon,
|
|
68
|
+
id: `${id}-option-${item.value}`,
|
|
69
|
+
})).join('');
|
|
70
|
+
|
|
71
|
+
if (!group.name) return rendered;
|
|
72
|
+
return `<div role="group" aria-labelledby="${escapeAttr(groupId)}">` +
|
|
73
|
+
`<span class="ol8-popup__group-label" id="${escapeAttr(groupId)}">${escapeText(group.name)}</span>` +
|
|
74
|
+
`${rendered}</div>`;
|
|
75
|
+
}).join('');
|
|
76
|
+
|
|
77
|
+
// A status is a region, never an option: "No results is never represented by
|
|
78
|
+
// a selectable option."
|
|
79
|
+
const statusRegion = status === 'none' ? '' :
|
|
80
|
+
`<div class="ol8-popup__status" data-ol8-status="${status}" role="status" aria-live="polite">` +
|
|
81
|
+
(status === 'loading' ? renderIcon('loading', { size: 18 }) : '') +
|
|
82
|
+
(status === 'error' ? renderIcon('critical', { size: 18 }) : '') +
|
|
83
|
+
`<span>${escapeText(statusText)}</span></div>`;
|
|
84
|
+
|
|
85
|
+
const attrs = [
|
|
86
|
+
`class="ol8-popup${className ? ` ${className}` : ''}"`,
|
|
87
|
+
`data-ol8-width="${width}"`,
|
|
88
|
+
`data-ol8-size="${size}"`,
|
|
89
|
+
].join(' ');
|
|
90
|
+
|
|
91
|
+
return `<div ${attrs}>` +
|
|
92
|
+
`<div class="ol8-popup__rail" role="listbox" id="${escapeAttr(id)}" aria-label="${escapeAttr(label)}"` +
|
|
93
|
+
(multiple ? ' aria-multiselectable="true"' : '') +
|
|
94
|
+
`>${rail}</div>` +
|
|
95
|
+
statusRegion +
|
|
96
|
+
`</div>`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function escapeAttr(v) { return String(v).replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<'); }
|
|
100
|
+
function escapeText(v) { return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './tab-bar.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { renderTabBar, hydrateTabBars, OL8_TAB_BAR_PRESENTATIONS } from './tab-bar.js';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Oneli8 · Tab Bar (ORGANISM)
|
|
3
|
+
* Figma: Navigation / Tab Bar 701:460 — Bottom, Inline, Sidebar, Spatial Rail.
|
|
4
|
+
*
|
|
5
|
+
* "Tab Bar contains destinations, never a central action disguised as a
|
|
6
|
+
* destination." Every presentation below keeps the same route identity and the
|
|
7
|
+
* same current destination; only the container changes.
|
|
8
|
+
*
|
|
9
|
+
* Read from Figma at Standard, Regular: Bottom, Sidebar and Spatial Rail all
|
|
10
|
+
* sit on a raised surface with a subtle border at the tab bar radius, protected
|
|
11
|
+
* by the tab bar inset. Inline has no surface and no border, and uses the
|
|
12
|
+
* tighter inline inset on its horizontal edges. Destinations meet with no gap.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
.ol8-tab-bar {
|
|
16
|
+
display: flex;
|
|
17
|
+
align-items: stretch;
|
|
18
|
+
box-sizing: border-box;
|
|
19
|
+
gap: var(--ol8-component-tab-bar-gap);
|
|
20
|
+
padding: var(--ol8-component-tab-bar-inset);
|
|
21
|
+
border: var(--ol8-border-width-hairline) solid var(--ol8-color-border-subtle);
|
|
22
|
+
border-radius: var(--ol8-component-tab-bar-radius);
|
|
23
|
+
background: var(--ol8-color-surface-raised);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.ol8-tab-bar > .ol8-nav-item { flex: 1 1 0; }
|
|
27
|
+
|
|
28
|
+
/* Inline sits in the page rather than on it, so it drops the surface, the
|
|
29
|
+
border and the radius, and protects only its horizontal edges. */
|
|
30
|
+
.ol8-tab-bar[data-ol8-presentation="inline"] {
|
|
31
|
+
padding-block: var(--ol8-component-tab-bar-inset);
|
|
32
|
+
padding-inline: var(--ol8-component-tab-bar-inline-inset);
|
|
33
|
+
border: 0;
|
|
34
|
+
border-radius: var(--ol8-shape-radius-none);
|
|
35
|
+
background: none;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.ol8-tab-bar[data-ol8-presentation="sidebar"],
|
|
39
|
+
.ol8-tab-bar[data-ol8-presentation="spatial-rail"] {
|
|
40
|
+
flex-direction: column;
|
|
41
|
+
align-items: stretch;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* The spatial rail carries no labels, so its destinations are square targets. */
|
|
45
|
+
.ol8-tab-bar[data-ol8-presentation="spatial-rail"] > .ol8-nav-item { flex: none; }
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tab Bar (ORGANISM). Figma 701:460. Native destination links, one current
|
|
3
|
+
* destination, and no disabled pseudo destinations.
|
|
4
|
+
*/
|
|
5
|
+
import type { Ol8NavigationSize } from '../tabs/tabs.js';
|
|
6
|
+
|
|
7
|
+
export type Ol8TabBarPresentation = 'bottom' | 'inline' | 'sidebar' | 'spatial-rail';
|
|
8
|
+
|
|
9
|
+
export declare const OL8_TAB_BAR_PRESENTATIONS: readonly Ol8TabBarPresentation[];
|
|
10
|
+
|
|
11
|
+
export interface Ol8Destination {
|
|
12
|
+
/** Required. A tab bar navigates, so it uses real links. */
|
|
13
|
+
href: string;
|
|
14
|
+
label?: string;
|
|
15
|
+
icon?: string;
|
|
16
|
+
badge?: string | number;
|
|
17
|
+
/** Required when the destination shows an icon and no label. */
|
|
18
|
+
ariaLabel?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface Ol8TabBarOptions {
|
|
22
|
+
/** Required navigation name, so a person can tell one landmark from another. */
|
|
23
|
+
label: string;
|
|
24
|
+
items: Ol8Destination[];
|
|
25
|
+
/** The href of the destination a person is on. */
|
|
26
|
+
current?: string;
|
|
27
|
+
presentation?: Ol8TabBarPresentation;
|
|
28
|
+
size?: Ol8NavigationSize;
|
|
29
|
+
material?: 'regular' | 'gem';
|
|
30
|
+
className?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** @throws without a name, without destinations, on a missing or duplicate href, on a disabled destination, or when current names nothing listed. */
|
|
34
|
+
export declare function renderTabBar(options: Ol8TabBarOptions): string;
|
|
35
|
+
|
|
36
|
+
/** Hands ordinary left clicks to a router while the link stays a real link. Returns the number of bars wired. */
|
|
37
|
+
export declare function hydrateTabBars(root?: ParentNode, onNavigate?: (href: string, event: MouseEvent) => void): number;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Oneli8 · Tab Bar — headless behavior.
|
|
3
|
+
*
|
|
4
|
+
* Figma (701:460): "Production uses native destination links, requires a non
|
|
5
|
+
* empty navigation name, resolves exactly one current destination, and rejects
|
|
6
|
+
* disabled pseudo destinations. Material and presentation never fork semantics."
|
|
7
|
+
*
|
|
8
|
+
* Every rule in that sentence is enforced below rather than described.
|
|
9
|
+
*/
|
|
10
|
+
import { renderNavigationItem } from '../../molecules/navigation-item/index.js';
|
|
11
|
+
|
|
12
|
+
export const OL8_TAB_BAR_PRESENTATIONS = ['bottom', 'inline', 'sidebar', 'spatial-rail'];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {{
|
|
16
|
+
* label:string,
|
|
17
|
+
* items:Array<{href:string,label?:string,icon?:string,badge?:any,ariaLabel?:string}>,
|
|
18
|
+
* current?:string, presentation?:string, size?:string, material?:string, className?:string,
|
|
19
|
+
* }} options
|
|
20
|
+
*/
|
|
21
|
+
export function renderTabBar(options) {
|
|
22
|
+
const {
|
|
23
|
+
label, items = [], current,
|
|
24
|
+
presentation = 'bottom', size = 'standard', material = 'regular', className,
|
|
25
|
+
} = options ?? {};
|
|
26
|
+
|
|
27
|
+
assertTabBar({ label, items, current, presentation });
|
|
28
|
+
|
|
29
|
+
// The spatial rail carries no labels, so a name has to come from somewhere.
|
|
30
|
+
const iconOnly = presentation === 'spatial-rail';
|
|
31
|
+
|
|
32
|
+
const destinations = items.map(item => renderNavigationItem({
|
|
33
|
+
kind: 'destination',
|
|
34
|
+
label: iconOnly ? undefined : item.label,
|
|
35
|
+
icon: item.icon,
|
|
36
|
+
badge: item.badge,
|
|
37
|
+
href: item.href,
|
|
38
|
+
selected: item.href === current,
|
|
39
|
+
ariaLabel: item.ariaLabel ?? (iconOnly ? item.label : undefined),
|
|
40
|
+
extraAttrs: `data-ol8-size="${size}"`,
|
|
41
|
+
})).join('');
|
|
42
|
+
|
|
43
|
+
const attrs = [
|
|
44
|
+
`class="ol8-tab-bar${className ? ` ${className}` : ''}"`,
|
|
45
|
+
`aria-label="${escapeAttr(label)}"`,
|
|
46
|
+
`data-ol8-presentation="${presentation}"`,
|
|
47
|
+
`data-ol8-size="${size}"`,
|
|
48
|
+
material === 'gem' ? 'data-ol8-material="gem"' : '',
|
|
49
|
+
].filter(Boolean).join(' ');
|
|
50
|
+
|
|
51
|
+
return `<nav ${attrs}>${destinations}</nav>`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Lets a product route without replacing link semantics. The link stays a real
|
|
56
|
+
* link, so opening it in a new tab, copying it and middle clicking all still
|
|
57
|
+
* work; only an ordinary left click is intercepted.
|
|
58
|
+
*/
|
|
59
|
+
export function hydrateTabBars(root = document, onNavigate) {
|
|
60
|
+
let count = 0;
|
|
61
|
+
for (const bar of root.querySelectorAll('.ol8-tab-bar')) {
|
|
62
|
+
if (bar.dataset.ol8Wired === 'true') continue;
|
|
63
|
+
bar.dataset.ol8Wired = 'true';
|
|
64
|
+
count += 1;
|
|
65
|
+
if (typeof onNavigate !== 'function') continue;
|
|
66
|
+
bar.addEventListener('click', (event) => {
|
|
67
|
+
if (event.defaultPrevented || event.button !== 0) return;
|
|
68
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
69
|
+
const link = event.target.closest('.ol8-nav-item--destination');
|
|
70
|
+
if (!link || link.target === '_blank') return;
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
onNavigate(link.getAttribute('href'), event);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return count;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function assertTabBar({ label, items, current, presentation }) {
|
|
79
|
+
if (typeof label !== 'string' || label === '') {
|
|
80
|
+
throw new Error('[ol8] a tab bar requires a navigation name, so a person can tell one landmark from another.');
|
|
81
|
+
}
|
|
82
|
+
if (!OL8_TAB_BAR_PRESENTATIONS.includes(presentation)) {
|
|
83
|
+
throw new Error(`[ol8] unknown tab bar presentation "${presentation}"`);
|
|
84
|
+
}
|
|
85
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
86
|
+
throw new Error('[ol8] a tab bar needs at least one destination.');
|
|
87
|
+
}
|
|
88
|
+
const seen = new Set();
|
|
89
|
+
for (const item of items) {
|
|
90
|
+
if (typeof item.href !== 'string' || item.href === '') {
|
|
91
|
+
throw new Error('[ol8] every destination needs an href. A tab bar navigates, so it uses real links.');
|
|
92
|
+
}
|
|
93
|
+
if (item.disabled) {
|
|
94
|
+
throw new Error('[ol8] a destination cannot be disabled. Remove the place rather than showing a door that does not open.');
|
|
95
|
+
}
|
|
96
|
+
if (seen.has(item.href)) throw new Error(`[ol8] two destinations share the href "${item.href}"`);
|
|
97
|
+
seen.add(item.href);
|
|
98
|
+
}
|
|
99
|
+
if (current !== undefined && !seen.has(current)) {
|
|
100
|
+
throw new Error(`[ol8] the current destination "${current}" is not one of the destinations listed.`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function escapeAttr(v) { return String(v).replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<'); }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './tabs.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { renderTabs, renderTabPanel, hydrateTabs, OL8_TABS_HIERARCHIES, OL8_TABS_ORIENTATIONS, OL8_TABS_ACTIVATIONS } from './tabs.js';
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Oneli8 · Tabs (ORGANISM)
|
|
3
|
+
* Figma: Navigation / Tabs 693:171 — Hierarchy, Material, Size.
|
|
4
|
+
*
|
|
5
|
+
* The organism owns sibling separation and the rail; the item owns its own
|
|
6
|
+
* inset. Read from Figma: tabs meet with no gap, and a Primary tab list carries
|
|
7
|
+
* an adaptive secondary rail behind them at the hairline thickness in the
|
|
8
|
+
* secondary text colour. Secondary and Tertiary carry no rail.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
.ol8-tabs {
|
|
12
|
+
position: relative;
|
|
13
|
+
display: flex;
|
|
14
|
+
align-items: stretch;
|
|
15
|
+
/* "Narrow Tab lists scroll or use an explicitly governed overflow policy;
|
|
16
|
+
labels never wrap into competing heights." */
|
|
17
|
+
overflow-x: auto;
|
|
18
|
+
scrollbar-width: none;
|
|
19
|
+
}
|
|
20
|
+
.ol8-tabs::-webkit-scrollbar { display: none; }
|
|
21
|
+
|
|
22
|
+
.ol8-tabs[data-ol8-hierarchy="primary"]::after {
|
|
23
|
+
content: "";
|
|
24
|
+
position: absolute;
|
|
25
|
+
inset-block-end: 0;
|
|
26
|
+
inset-inline: 0;
|
|
27
|
+
block-size: var(--ol8-component-tab-item-rail);
|
|
28
|
+
background: var(--ol8-color-text-secondary);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/* Vertical tabs turn the rail onto the logical inline edge and let each tab
|
|
32
|
+
fill the width, so the indicator reads as a column marker. */
|
|
33
|
+
.ol8-tabs[data-ol8-orientation="vertical"] {
|
|
34
|
+
flex-direction: column;
|
|
35
|
+
overflow-x: visible;
|
|
36
|
+
}
|
|
37
|
+
.ol8-tabs[data-ol8-orientation="vertical"][data-ol8-hierarchy="primary"]::after {
|
|
38
|
+
inset-block: 0;
|
|
39
|
+
inset-inline: auto 0;
|
|
40
|
+
inline-size: var(--ol8-component-tab-item-rail);
|
|
41
|
+
block-size: auto;
|
|
42
|
+
}
|
|
43
|
+
.ol8-tabs[data-ol8-orientation="vertical"] .ol8-nav-item__indicator {
|
|
44
|
+
inset-block: var(--ol8-component-tab-item-indicator-inset);
|
|
45
|
+
inset-inline: auto 0;
|
|
46
|
+
inline-size: var(--ol8-component-tab-item-indicator-thickness);
|
|
47
|
+
block-size: auto;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.ol8-tabs__panel:focus-visible {
|
|
51
|
+
outline: none;
|
|
52
|
+
box-shadow: inset 0 0 0 var(--ol8-component-navigation-focus-inset) var(--ol8-color-focus-inner);
|
|
53
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tabs (ORGANISM). Figma 693:171. Emits tablist, tab and tabpanel, keeps one
|
|
3
|
+
* enabled tab stop, mirrors horizontal arrows in right to left, and supports
|
|
4
|
+
* both orientations and both activation modes.
|
|
5
|
+
*/
|
|
6
|
+
export type Ol8TabsHierarchy = 'primary' | 'secondary' | 'tertiary';
|
|
7
|
+
export type Ol8TabsOrientation = 'horizontal' | 'vertical';
|
|
8
|
+
export type Ol8TabsActivation = 'automatic' | 'manual';
|
|
9
|
+
export type Ol8NavigationSize = 'compact' | 'standard' | 'comfortable' | 'large';
|
|
10
|
+
|
|
11
|
+
export declare const OL8_TABS_HIERARCHIES: readonly Ol8TabsHierarchy[];
|
|
12
|
+
export declare const OL8_TABS_ORIENTATIONS: readonly Ol8TabsOrientation[];
|
|
13
|
+
export declare const OL8_TABS_ACTIVATIONS: readonly Ol8TabsActivation[];
|
|
14
|
+
|
|
15
|
+
export interface Ol8TabItem {
|
|
16
|
+
/** Required: exactly one panel is paired with it. */
|
|
17
|
+
id: string;
|
|
18
|
+
label?: string;
|
|
19
|
+
icon?: string;
|
|
20
|
+
badge?: string | number;
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
/** Required when the item shows an icon and no label. */
|
|
23
|
+
ariaLabel?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Ol8TabsOptions {
|
|
27
|
+
/** Required. A region nobody can name is a region nobody can reach. */
|
|
28
|
+
label: string;
|
|
29
|
+
items: Ol8TabItem[];
|
|
30
|
+
selected?: string;
|
|
31
|
+
hierarchy?: Ol8TabsHierarchy;
|
|
32
|
+
size?: Ol8NavigationSize;
|
|
33
|
+
material?: 'regular' | 'gem';
|
|
34
|
+
orientation?: Ol8TabsOrientation;
|
|
35
|
+
activation?: Ol8TabsActivation;
|
|
36
|
+
/** Shared with renderTabPanel so each tab owns its panel. */
|
|
37
|
+
idPrefix?: string;
|
|
38
|
+
className?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @throws without a name, without tabs, when every tab is disabled, or on duplicate ids. */
|
|
42
|
+
export declare function renderTabs(options: Ol8TabsOptions): string;
|
|
43
|
+
|
|
44
|
+
/** @throws without the idPrefix that pairs the panel with its tab. */
|
|
45
|
+
export declare function renderTabPanel(tabId: string, content: string, options: { idPrefix: string; hidden?: boolean }): string;
|
|
46
|
+
|
|
47
|
+
/** Wires arrow keys, Home, End, activation and panel visibility. Returns the number of lists wired. */
|
|
48
|
+
export declare function hydrateTabs(root?: ParentNode): number;
|