luxen-ui 0.16.2 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -49
- package/cdn/custom-elements.json +210 -0
- package/cdn/elements/avatar/avatar.d.ts.map +1 -1
- package/cdn/elements/avatar/avatar.js +1 -1
- package/cdn/elements/avatar/avatar.js.map +1 -1
- package/cdn/elements/segmented-control/index.d.ts +2 -0
- package/cdn/elements/segmented-control/index.d.ts.map +1 -0
- package/cdn/elements/segmented-control/index.js +2 -0
- package/cdn/elements/segmented-control/index.js.map +1 -0
- package/cdn/elements/segmented-control/segmented-control.d.ts +80 -0
- package/cdn/elements/segmented-control/segmented-control.d.ts.map +1 -0
- package/cdn/elements/segmented-control/segmented-control.js +2 -0
- package/cdn/elements/segmented-control/segmented-control.js.map +1 -0
- package/cdn/registry.d.ts +1 -1
- package/cdn/registry.d.ts.map +1 -1
- package/cdn/registry.js.map +1 -1
- package/cdn/standalone.css +167 -0
- package/cdn/standalone.js +25 -22
- package/cdn/standalone.js.map +1 -1
- package/cdn/styles/elements/segmented-control.css +165 -0
- package/dist/css/elements/segmented-control.css +165 -0
- package/dist/custom-elements.json +210 -0
- package/dist/elements/avatar/avatar.d.ts.map +1 -1
- package/dist/elements/avatar/avatar.js +27 -7
- package/dist/elements/segmented-control/index.d.ts +2 -0
- package/dist/elements/segmented-control/index.d.ts.map +1 -0
- package/dist/elements/segmented-control/index.js +4 -0
- package/dist/elements/segmented-control/segmented-control.d.ts +80 -0
- package/dist/elements/segmented-control/segmented-control.d.ts.map +1 -0
- package/dist/elements/segmented-control/segmented-control.js +307 -0
- package/dist/metadata/index.json +106 -1
- package/dist/metadata/segmented-control.json +105 -0
- package/dist/registry.d.ts +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/templates/elements/segmented-control.md +252 -0
- package/elements.json +8 -0
- package/package.json +1 -1
|
@@ -6041,6 +6041,216 @@
|
|
|
6041
6041
|
}
|
|
6042
6042
|
]
|
|
6043
6043
|
},
|
|
6044
|
+
{
|
|
6045
|
+
"kind": "javascript-module",
|
|
6046
|
+
"path": "src/html/elements/segmented-control/index.ts",
|
|
6047
|
+
"declarations": [],
|
|
6048
|
+
"exports": [
|
|
6049
|
+
{
|
|
6050
|
+
"kind": "js",
|
|
6051
|
+
"name": "*",
|
|
6052
|
+
"declaration": {
|
|
6053
|
+
"name": "*",
|
|
6054
|
+
"module": "src/html/elements/segmented-control/segmented-control.js"
|
|
6055
|
+
}
|
|
6056
|
+
}
|
|
6057
|
+
]
|
|
6058
|
+
},
|
|
6059
|
+
{
|
|
6060
|
+
"kind": "javascript-module",
|
|
6061
|
+
"path": "src/html/elements/segmented-control/segmented-control.ts",
|
|
6062
|
+
"declarations": [
|
|
6063
|
+
{
|
|
6064
|
+
"kind": "class",
|
|
6065
|
+
"description": "Fired when the selected segment changes. Bubbles; not composed.",
|
|
6066
|
+
"name": "SegmentChangeEvent",
|
|
6067
|
+
"members": [
|
|
6068
|
+
{
|
|
6069
|
+
"kind": "field",
|
|
6070
|
+
"name": "value",
|
|
6071
|
+
"type": {
|
|
6072
|
+
"text": "string"
|
|
6073
|
+
},
|
|
6074
|
+
"readonly": true,
|
|
6075
|
+
"default": "value"
|
|
6076
|
+
},
|
|
6077
|
+
{
|
|
6078
|
+
"kind": "field",
|
|
6079
|
+
"name": "index",
|
|
6080
|
+
"type": {
|
|
6081
|
+
"text": "number"
|
|
6082
|
+
},
|
|
6083
|
+
"readonly": true,
|
|
6084
|
+
"default": "index"
|
|
6085
|
+
}
|
|
6086
|
+
],
|
|
6087
|
+
"superclass": {
|
|
6088
|
+
"name": "Event",
|
|
6089
|
+
"module": "src/html/elements/segmented-control/segmented-control.ts"
|
|
6090
|
+
}
|
|
6091
|
+
},
|
|
6092
|
+
{
|
|
6093
|
+
"kind": "class",
|
|
6094
|
+
"description": "",
|
|
6095
|
+
"name": "SegmentedControl",
|
|
6096
|
+
"cssProperties": [
|
|
6097
|
+
{
|
|
6098
|
+
"description": "Background of the sliding pill behind the selected segment.",
|
|
6099
|
+
"name": "--indicator-color",
|
|
6100
|
+
"default": "var(--l-color-surface)"
|
|
6101
|
+
},
|
|
6102
|
+
{
|
|
6103
|
+
"description": "Corner radius of the track.",
|
|
6104
|
+
"name": "--border-radius",
|
|
6105
|
+
"default": "var(--l-radius-lg)"
|
|
6106
|
+
}
|
|
6107
|
+
],
|
|
6108
|
+
"members": [
|
|
6109
|
+
{
|
|
6110
|
+
"kind": "field",
|
|
6111
|
+
"name": "value",
|
|
6112
|
+
"type": {
|
|
6113
|
+
"text": "string"
|
|
6114
|
+
},
|
|
6115
|
+
"default": "''",
|
|
6116
|
+
"description": "Value of the selected segment (its `value` attribute, else its index).",
|
|
6117
|
+
"attribute": "value",
|
|
6118
|
+
"reflects": true
|
|
6119
|
+
},
|
|
6120
|
+
{
|
|
6121
|
+
"kind": "field",
|
|
6122
|
+
"name": "size",
|
|
6123
|
+
"type": {
|
|
6124
|
+
"text": "SegmentedControlSize"
|
|
6125
|
+
},
|
|
6126
|
+
"default": "'md'",
|
|
6127
|
+
"description": "Control height, aligned with buttons and form controls at the same size.",
|
|
6128
|
+
"attribute": "size",
|
|
6129
|
+
"reflects": true
|
|
6130
|
+
},
|
|
6131
|
+
{
|
|
6132
|
+
"kind": "field",
|
|
6133
|
+
"name": "label",
|
|
6134
|
+
"type": {
|
|
6135
|
+
"text": "string | undefined"
|
|
6136
|
+
},
|
|
6137
|
+
"description": "Accessible label announced for the group. Not displayed on screen.",
|
|
6138
|
+
"attribute": "label",
|
|
6139
|
+
"reflects": true
|
|
6140
|
+
},
|
|
6141
|
+
{
|
|
6142
|
+
"kind": "field",
|
|
6143
|
+
"name": "fullWidth",
|
|
6144
|
+
"type": {
|
|
6145
|
+
"text": "boolean"
|
|
6146
|
+
},
|
|
6147
|
+
"default": "false",
|
|
6148
|
+
"description": "Stretch segments to fill the container width.",
|
|
6149
|
+
"attribute": "full-width",
|
|
6150
|
+
"reflects": true
|
|
6151
|
+
},
|
|
6152
|
+
{
|
|
6153
|
+
"kind": "method",
|
|
6154
|
+
"name": "formResetCallback"
|
|
6155
|
+
},
|
|
6156
|
+
{
|
|
6157
|
+
"kind": "method",
|
|
6158
|
+
"name": "formStateRestoreCallback",
|
|
6159
|
+
"parameters": [
|
|
6160
|
+
{
|
|
6161
|
+
"name": "state",
|
|
6162
|
+
"type": {
|
|
6163
|
+
"text": "string"
|
|
6164
|
+
}
|
|
6165
|
+
},
|
|
6166
|
+
{
|
|
6167
|
+
"name": "mode",
|
|
6168
|
+
"type": {
|
|
6169
|
+
"text": "'restore' | 'autocomplete'"
|
|
6170
|
+
}
|
|
6171
|
+
}
|
|
6172
|
+
]
|
|
6173
|
+
}
|
|
6174
|
+
],
|
|
6175
|
+
"events": [
|
|
6176
|
+
{
|
|
6177
|
+
"name": "change",
|
|
6178
|
+
"description": "Fired when the selected segment changes. Not cancelable. Bubbles. Properties: `value: string`, `index: number`.",
|
|
6179
|
+
"cancelable": false
|
|
6180
|
+
}
|
|
6181
|
+
],
|
|
6182
|
+
"attributes": [
|
|
6183
|
+
{
|
|
6184
|
+
"name": "value",
|
|
6185
|
+
"type": {
|
|
6186
|
+
"text": "string"
|
|
6187
|
+
},
|
|
6188
|
+
"default": "''",
|
|
6189
|
+
"description": "Value of the selected segment (its `value` attribute, else its index).",
|
|
6190
|
+
"fieldName": "value"
|
|
6191
|
+
},
|
|
6192
|
+
{
|
|
6193
|
+
"name": "size",
|
|
6194
|
+
"type": {
|
|
6195
|
+
"text": "SegmentedControlSize"
|
|
6196
|
+
},
|
|
6197
|
+
"default": "'md'",
|
|
6198
|
+
"description": "Control height, aligned with buttons and form controls at the same size.",
|
|
6199
|
+
"fieldName": "size"
|
|
6200
|
+
},
|
|
6201
|
+
{
|
|
6202
|
+
"name": "label",
|
|
6203
|
+
"type": {
|
|
6204
|
+
"text": "string | undefined"
|
|
6205
|
+
},
|
|
6206
|
+
"description": "Accessible label announced for the group. Not displayed on screen.",
|
|
6207
|
+
"fieldName": "label"
|
|
6208
|
+
},
|
|
6209
|
+
{
|
|
6210
|
+
"name": "full-width",
|
|
6211
|
+
"type": {
|
|
6212
|
+
"text": "boolean"
|
|
6213
|
+
},
|
|
6214
|
+
"default": "false",
|
|
6215
|
+
"description": "Stretch segments to fill the container width.",
|
|
6216
|
+
"fieldName": "fullWidth"
|
|
6217
|
+
}
|
|
6218
|
+
],
|
|
6219
|
+
"superclass": {
|
|
6220
|
+
"name": "LuxenFormAssociatedElement",
|
|
6221
|
+
"module": "/src/html/shared/luxen-form-associated-element.js"
|
|
6222
|
+
},
|
|
6223
|
+
"tagName": "l-segmented-control",
|
|
6224
|
+
"customElement": true,
|
|
6225
|
+
"summary": "A single-select segmented control: a compact, mutually-exclusive\nswitch between a few options (radio-group semantics with a sliding pill).\nProgressively enhances light-DOM `<button>`s so it works before JS runs.\nForm-associated: give it a `name` and its selected `value` is submitted with\nthe form (and restored on reset), like a native radio group.",
|
|
6226
|
+
"examples": [
|
|
6227
|
+
{
|
|
6228
|
+
"title": "Mark the initially-selected segment with `aria-checked=\"true\"` (or set",
|
|
6229
|
+
"language": "html",
|
|
6230
|
+
"code": "`value` to its `value` attribute) so it is styled before the script upgrades\nthe element.\n```html\n<l-segmented-control label=\"Metric\" value=\"cost\">\n <button value=\"volume\">Volume</button>\n <button value=\"cost\" aria-checked=\"true\">Cost</button>\n</l-segmented-control>\n```"
|
|
6231
|
+
}
|
|
6232
|
+
]
|
|
6233
|
+
}
|
|
6234
|
+
],
|
|
6235
|
+
"exports": [
|
|
6236
|
+
{
|
|
6237
|
+
"kind": "js",
|
|
6238
|
+
"name": "SegmentChangeEvent",
|
|
6239
|
+
"declaration": {
|
|
6240
|
+
"name": "SegmentChangeEvent",
|
|
6241
|
+
"module": "src/html/elements/segmented-control/segmented-control.ts"
|
|
6242
|
+
}
|
|
6243
|
+
},
|
|
6244
|
+
{
|
|
6245
|
+
"kind": "js",
|
|
6246
|
+
"name": "SegmentedControl",
|
|
6247
|
+
"declaration": {
|
|
6248
|
+
"name": "SegmentedControl",
|
|
6249
|
+
"module": "src/html/elements/segmented-control/segmented-control.ts"
|
|
6250
|
+
}
|
|
6251
|
+
}
|
|
6252
|
+
]
|
|
6253
|
+
},
|
|
6044
6254
|
{
|
|
6045
6255
|
"kind": "javascript-module",
|
|
6046
6256
|
"path": "src/html/elements/select/index.ts",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"avatar.d.ts","sourceRoot":"","sources":["../../../src/html/elements/avatar/avatar.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,MAAM,+BAA+B,CAAC;
|
|
1
|
+
{"version":3,"file":"avatar.d.ts","sourceRoot":"","sources":["../../../src/html/elements/avatar/avatar.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,MAAM,+BAA+B,CAAC;AA0C7D;;;;;;;;;;;;GAYG;AACH,qBAAa,MAAO,SAAQ,YAAY;IACtC,MAAM,CAAC,MAAM,4BAAwB;IAErC,qFAAqF;IAErF,GAAG,SAAM;IAET,6EAA6E;IAE7E,IAAI,SAAM;IAEV,8DAA8D;IAE9D,IAAI,SAAQ;IAEZ,4DAA4D;IAE5D,KAAK,SAAK;IAEV,+DAA+D;IAE/D,WAAW,UAAS;IAEX,OAAO,CAAC,SAAS,CAAS;IAEnC,UAAU,CAAC,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC;IAMxC,iBAAiB;IAOjB,OAAO;IAgBP,OAAO,KAAK,IAAI,GAEf;IAED,MAAM;IAkCN,OAAO,CAAC,QAAQ,CAEd;CACH"}
|
|
@@ -11,13 +11,33 @@ import { property, state } from 'lit/decorators.js';
|
|
|
11
11
|
import hostStyles from '../../shared/styles/host.styles.js';
|
|
12
12
|
import rawStyles from './avatar.css?inline';
|
|
13
13
|
const styles = unsafeCSS(rawStyles);
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Extracts up to two uppercase initials from a name: the first letter of the
|
|
16
|
+
* first word, plus the first letter of the last word when there is more than one.
|
|
17
|
+
*
|
|
18
|
+
* Splits on whitespace rather than relying on a `\b`/`\w` regex, which only
|
|
19
|
+
* recognises ASCII and mangles accented or non-ASCII names.
|
|
20
|
+
*
|
|
21
|
+
* @param name - The name to extract initials from. Defaults to an empty string.
|
|
22
|
+
* @return The uppercased initials, or an empty string when `name` is blank.
|
|
23
|
+
*
|
|
24
|
+
* @example getInitials('John Doe') // 'JD'
|
|
25
|
+
* @example getInitials('Markus Nösterer') // 'MN' (accents preserved)
|
|
26
|
+
* @example getInitials('Cher') // 'C' (single word → one initial)
|
|
27
|
+
* @example getInitials(' John Doe ') // 'JD' (extra whitespace ignored)
|
|
28
|
+
* @example getInitials('') // ''
|
|
29
|
+
*/
|
|
30
|
+
function getInitials(name = '') {
|
|
31
|
+
const words = name.normalize('NFC').trim().split(/\s+/).filter(Boolean);
|
|
32
|
+
if (words.length === 0)
|
|
33
|
+
return '';
|
|
34
|
+
// Spreading iterates by code point, so an accented letter (or an astral
|
|
35
|
+
// character like an emoji) is taken whole instead of a broken half.
|
|
36
|
+
// oxlint-disable-next-line typescript/no-misused-spread -- code-point-safe first char is the intent
|
|
37
|
+
const firstLetter = (word) => [...word][0];
|
|
38
|
+
const first = firstLetter(words[0]);
|
|
39
|
+
const last = words.length > 1 ? firstLetter(words[words.length - 1]) : '';
|
|
40
|
+
return (first + last).toUpperCase();
|
|
21
41
|
}
|
|
22
42
|
const defaultIcon = svg `<svg class="icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
23
43
|
<path d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,14C16.42,14 20,15.79 20,18V20H4V18C4,15.79 7.58,14 12,14Z" />
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/html/elements/segmented-control/index.ts"],"names":[],"mappings":"AAEA,cAAc,wBAAwB,CAAC"}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { LuxenFormAssociatedElement } from '../../shared/luxen-form-associated-element.js';
|
|
2
|
+
export type SegmentedControlSize = 'sm' | 'md' | 'lg' | 'xl';
|
|
3
|
+
/** Fired when the selected segment changes. Bubbles; not composed. */
|
|
4
|
+
export declare class SegmentChangeEvent extends Event {
|
|
5
|
+
readonly value: string;
|
|
6
|
+
readonly index: number;
|
|
7
|
+
constructor(value: string, index: number);
|
|
8
|
+
}
|
|
9
|
+
interface SegmentedControlEventMap {
|
|
10
|
+
change: SegmentChangeEvent;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* @summary A single-select segmented control: a compact, mutually-exclusive
|
|
14
|
+
* switch between a few options (radio-group semantics with a sliding pill).
|
|
15
|
+
* Progressively enhances light-DOM `<button>`s so it works before JS runs.
|
|
16
|
+
* Form-associated: give it a `name` and its selected `value` is submitted with
|
|
17
|
+
* the form (and restored on reset), like a native radio group.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* Mark the initially-selected segment with `aria-checked="true"` (or set
|
|
21
|
+
* `value` to its `value` attribute) so it is styled before the script upgrades
|
|
22
|
+
* the element.
|
|
23
|
+
* ```html
|
|
24
|
+
* <l-segmented-control label="Metric" value="cost">
|
|
25
|
+
* <button value="volume">Volume</button>
|
|
26
|
+
* <button value="cost" aria-checked="true">Cost</button>
|
|
27
|
+
* </l-segmented-control>
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* @event change - Fired when the selected segment changes. Not cancelable. Bubbles. Properties: `value: string`, `index: number`.
|
|
31
|
+
*
|
|
32
|
+
* @cssproperty [--indicator-color=var(--l-color-surface)] - Background of the sliding pill behind the selected segment.
|
|
33
|
+
* @cssproperty [--border-radius=var(--l-radius-lg)] - Corner radius of the track.
|
|
34
|
+
*
|
|
35
|
+
* @customElement l-segmented-control
|
|
36
|
+
*/
|
|
37
|
+
export declare class SegmentedControl extends LuxenFormAssociatedElement {
|
|
38
|
+
createRenderRoot(): this;
|
|
39
|
+
private _initialized;
|
|
40
|
+
private _setupTimer;
|
|
41
|
+
private _buttons;
|
|
42
|
+
private _resizeObserver;
|
|
43
|
+
/** Value of the selected segment (its `value` attribute, else its index). */
|
|
44
|
+
value: string;
|
|
45
|
+
/** Control height, aligned with buttons and form controls at the same size. */
|
|
46
|
+
size: SegmentedControlSize;
|
|
47
|
+
/** Accessible label announced for the group. Not displayed on screen. */
|
|
48
|
+
label?: string;
|
|
49
|
+
/** Stretch segments to fill the container width. */
|
|
50
|
+
fullWidth: boolean;
|
|
51
|
+
connectedCallback(): void;
|
|
52
|
+
disconnectedCallback(): void;
|
|
53
|
+
/** @returns true when setup ran or was already done; false to schedule a retry. */
|
|
54
|
+
private _trySetup;
|
|
55
|
+
updated(changed: Map<string, unknown>): void;
|
|
56
|
+
formResetCallback(): void;
|
|
57
|
+
formStateRestoreCallback(state: string, mode: 'restore' | 'autocomplete'): void;
|
|
58
|
+
private _setup;
|
|
59
|
+
private _teardown;
|
|
60
|
+
/** Resolve the selected index from `value`, else an authored `aria-checked`, else 0. */
|
|
61
|
+
private _resolveActiveIndex;
|
|
62
|
+
private _syncValueFromIndex;
|
|
63
|
+
private _select;
|
|
64
|
+
/** Index of the next non-disabled segment from `from`, moving by `dir`, wrapping. */
|
|
65
|
+
private _nextEnabled;
|
|
66
|
+
private _isDisabled;
|
|
67
|
+
/** Reflect the host `disabled` state onto the segments (aria + tab order). */
|
|
68
|
+
private _applyDisabledState;
|
|
69
|
+
private _updateIndicator;
|
|
70
|
+
private _onClick;
|
|
71
|
+
private _onKeyDown;
|
|
72
|
+
}
|
|
73
|
+
export interface SegmentedControl {
|
|
74
|
+
addEventListener<K extends keyof SegmentedControlEventMap>(type: K, listener: (this: SegmentedControl, ev: SegmentedControlEventMap[K]) => void, options?: boolean | AddEventListenerOptions): void;
|
|
75
|
+
addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
|
|
76
|
+
removeEventListener<K extends keyof SegmentedControlEventMap>(type: K, listener: (this: SegmentedControl, ev: SegmentedControlEventMap[K]) => void, options?: boolean | EventListenerOptions): void;
|
|
77
|
+
removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
|
|
78
|
+
}
|
|
79
|
+
export {};
|
|
80
|
+
//# sourceMappingURL=segmented-control.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"segmented-control.d.ts","sourceRoot":"","sources":["../../../src/html/elements/segmented-control/segmented-control.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,0BAA0B,EAAE,MAAM,+CAA+C,CAAC;AAE3F,MAAM,MAAM,oBAAoB,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;AAE7D,sEAAsE;AACtE,qBAAa,kBAAmB,SAAQ,KAAK;IAC3C,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;gBACX,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM;CAKzC;AAED,UAAU,wBAAwB;IAChC,MAAM,EAAE,kBAAkB,CAAC;CAC5B;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,qBAAa,gBAAiB,SAAQ,0BAA0B;IACrD,gBAAgB;IAIzB,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,WAAW,CAAK;IACxB,OAAO,CAAC,QAAQ,CAA2B;IAC3C,OAAO,CAAC,eAAe,CAA+B;IAEtD,6EAA6E;IAE7E,KAAK,SAAM;IAEX,+EAA+E;IAE/E,IAAI,EAAE,oBAAoB,CAAQ;IAElC,yEAAyE;IAEzE,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf,oDAAoD;IAEpD,SAAS,UAAS;IAIT,iBAAiB;IAUjB,oBAAoB;IAM7B,mFAAmF;IACnF,OAAO,CAAC,SAAS;IAOR,OAAO,CAAC,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC;IAsBrC,iBAAiB;IAMjB,wBAAwB,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,GAAG,cAAc;IAOjF,OAAO,CAAC,MAAM;IAoDd,OAAO,CAAC,SAAS;IAUjB,wFAAwF;IACxF,OAAO,CAAC,mBAAmB;IAe3B,OAAO,CAAC,mBAAmB;IAI3B,OAAO,CAAC,OAAO;IAuBf,qFAAqF;IACrF,OAAO,CAAC,YAAY;IASpB,OAAO,CAAC,WAAW;IAInB,8EAA8E;IAC9E,OAAO,CAAC,mBAAmB;IAe3B,OAAO,CAAC,gBAAgB;IASxB,OAAO,CAAC,QAAQ,CAQd;IAKF,OAAO,CAAC,UAAU,CAkChB;CACH;AAQD,MAAM,WAAW,gBAAgB;IAC/B,gBAAgB,CAAC,CAAC,SAAS,MAAM,wBAAwB,EACvD,IAAI,EAAE,CAAC,EACP,QAAQ,EAAE,CAAC,IAAI,EAAE,gBAAgB,EAAE,EAAE,EAAE,wBAAwB,CAAC,CAAC,CAAC,KAAK,IAAI,EAC3E,OAAO,CAAC,EAAE,OAAO,GAAG,uBAAuB,GAC1C,IAAI,CAAC;IACR,gBAAgB,CACd,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,kCAAkC,EAC5C,OAAO,CAAC,EAAE,OAAO,GAAG,uBAAuB,GAC1C,IAAI,CAAC;IACR,mBAAmB,CAAC,CAAC,SAAS,MAAM,wBAAwB,EAC1D,IAAI,EAAE,CAAC,EACP,QAAQ,EAAE,CAAC,IAAI,EAAE,gBAAgB,EAAE,EAAE,EAAE,wBAAwB,CAAC,CAAC,CAAC,KAAK,IAAI,EAC3E,OAAO,CAAC,EAAE,OAAO,GAAG,oBAAoB,GACvC,IAAI,CAAC;IACR,mBAAmB,CACjB,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,kCAAkC,EAC5C,OAAO,CAAC,EAAE,OAAO,GAAG,oBAAoB,GACvC,IAAI,CAAC;CACT"}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
import { property } from 'lit/decorators.js';
|
|
8
|
+
import { LuxenFormAssociatedElement } from '../../shared/luxen-form-associated-element.js';
|
|
9
|
+
/** Fired when the selected segment changes. Bubbles; not composed. */
|
|
10
|
+
export class SegmentChangeEvent extends Event {
|
|
11
|
+
constructor(value, index) {
|
|
12
|
+
super('change', { bubbles: true, composed: false, cancelable: false });
|
|
13
|
+
this.value = value;
|
|
14
|
+
this.index = index;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* @summary A single-select segmented control: a compact, mutually-exclusive
|
|
19
|
+
* switch between a few options (radio-group semantics with a sliding pill).
|
|
20
|
+
* Progressively enhances light-DOM `<button>`s so it works before JS runs.
|
|
21
|
+
* Form-associated: give it a `name` and its selected `value` is submitted with
|
|
22
|
+
* the form (and restored on reset), like a native radio group.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* Mark the initially-selected segment with `aria-checked="true"` (or set
|
|
26
|
+
* `value` to its `value` attribute) so it is styled before the script upgrades
|
|
27
|
+
* the element.
|
|
28
|
+
* ```html
|
|
29
|
+
* <l-segmented-control label="Metric" value="cost">
|
|
30
|
+
* <button value="volume">Volume</button>
|
|
31
|
+
* <button value="cost" aria-checked="true">Cost</button>
|
|
32
|
+
* </l-segmented-control>
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* @event change - Fired when the selected segment changes. Not cancelable. Bubbles. Properties: `value: string`, `index: number`.
|
|
36
|
+
*
|
|
37
|
+
* @cssproperty [--indicator-color=var(--l-color-surface)] - Background of the sliding pill behind the selected segment.
|
|
38
|
+
* @cssproperty [--border-radius=var(--l-radius-lg)] - Corner radius of the track.
|
|
39
|
+
*
|
|
40
|
+
* @customElement l-segmented-control
|
|
41
|
+
*/
|
|
42
|
+
// oxlint-disable-next-line typescript/no-unsafe-declaration-merging -- typed addEventListener overloads merged below; no uninitialized properties.
|
|
43
|
+
export class SegmentedControl extends LuxenFormAssociatedElement {
|
|
44
|
+
constructor() {
|
|
45
|
+
super(...arguments);
|
|
46
|
+
this._initialized = false;
|
|
47
|
+
this._setupTimer = 0;
|
|
48
|
+
this._buttons = [];
|
|
49
|
+
this._resizeObserver = null;
|
|
50
|
+
/** Value of the selected segment (its `value` attribute, else its index). */
|
|
51
|
+
this.value = '';
|
|
52
|
+
/** Control height, aligned with buttons and form controls at the same size. */
|
|
53
|
+
this.size = 'md';
|
|
54
|
+
/** Stretch segments to fill the container width. */
|
|
55
|
+
this.fullWidth = false;
|
|
56
|
+
// --- Event handlers ---
|
|
57
|
+
this._onClick = (e) => {
|
|
58
|
+
if (this.disabled)
|
|
59
|
+
return;
|
|
60
|
+
const btn = e.target.closest('button');
|
|
61
|
+
if (!btn)
|
|
62
|
+
return;
|
|
63
|
+
const index = this._buttons.indexOf(btn);
|
|
64
|
+
if (index < 0 || this._isDisabled(btn))
|
|
65
|
+
return;
|
|
66
|
+
this._select(index);
|
|
67
|
+
btn.focus();
|
|
68
|
+
};
|
|
69
|
+
// Typed `Event` (not `KeyboardEvent`): the listener is attached to `this`,
|
|
70
|
+
// whose merged addEventListener overloads (see the interface below) fall back
|
|
71
|
+
// to the platform `EventListener` signature for non-map events like `keydown`.
|
|
72
|
+
this._onKeyDown = (e) => {
|
|
73
|
+
if (this.disabled)
|
|
74
|
+
return;
|
|
75
|
+
const ke = e;
|
|
76
|
+
const target = e.target;
|
|
77
|
+
if (target.getAttribute('role') !== 'radio')
|
|
78
|
+
return;
|
|
79
|
+
const current = this._buttons.indexOf(target);
|
|
80
|
+
if (current < 0)
|
|
81
|
+
return;
|
|
82
|
+
let next = current;
|
|
83
|
+
switch (ke.key) {
|
|
84
|
+
case 'ArrowRight':
|
|
85
|
+
case 'ArrowDown':
|
|
86
|
+
next = this._nextEnabled(current, 1);
|
|
87
|
+
break;
|
|
88
|
+
case 'ArrowLeft':
|
|
89
|
+
case 'ArrowUp':
|
|
90
|
+
next = this._nextEnabled(current, -1);
|
|
91
|
+
break;
|
|
92
|
+
case 'Home':
|
|
93
|
+
next = this._nextEnabled(-1, 1);
|
|
94
|
+
break;
|
|
95
|
+
case 'End':
|
|
96
|
+
next = this._nextEnabled(this._buttons.length, -1);
|
|
97
|
+
break;
|
|
98
|
+
case ' ':
|
|
99
|
+
// Space (re)selects the focused segment (APG radio group).
|
|
100
|
+
break;
|
|
101
|
+
default:
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
e.preventDefault();
|
|
105
|
+
this._select(next);
|
|
106
|
+
this._buttons[next].focus();
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
createRenderRoot() {
|
|
110
|
+
return this;
|
|
111
|
+
}
|
|
112
|
+
// --- Lifecycle ---
|
|
113
|
+
connectedCallback() {
|
|
114
|
+
super.connectedCallback();
|
|
115
|
+
// Children may not be parsed yet when the element upgrades mid-parse: try
|
|
116
|
+
// synchronously, then retry once on a macrotask. setTimeout, not rAF — rAF
|
|
117
|
+
// is suspended in hidden documents, so the element would stay inert there.
|
|
118
|
+
if (!this._trySetup()) {
|
|
119
|
+
this._setupTimer = window.setTimeout(() => this._trySetup(), 0);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
disconnectedCallback() {
|
|
123
|
+
super.disconnectedCallback();
|
|
124
|
+
clearTimeout(this._setupTimer);
|
|
125
|
+
this._teardown();
|
|
126
|
+
}
|
|
127
|
+
/** @returns true when setup ran or was already done; false to schedule a retry. */
|
|
128
|
+
_trySetup() {
|
|
129
|
+
if (this._initialized || !this.isConnected)
|
|
130
|
+
return true;
|
|
131
|
+
if (this.querySelectorAll('button').length < 2)
|
|
132
|
+
return false;
|
|
133
|
+
this._setup();
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
updated(changed) {
|
|
137
|
+
if (!this._initialized)
|
|
138
|
+
return;
|
|
139
|
+
if (changed.has('value')) {
|
|
140
|
+
this._select(this._resolveActiveIndex(), false);
|
|
141
|
+
}
|
|
142
|
+
if (changed.has('size')) {
|
|
143
|
+
this._updateIndicator();
|
|
144
|
+
}
|
|
145
|
+
if (changed.has('label')) {
|
|
146
|
+
if (this.label) {
|
|
147
|
+
this.setAttribute('aria-label', this.label);
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
this.removeAttribute('aria-label');
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (changed.has('disabled')) {
|
|
154
|
+
this._applyDisabledState();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// --- Form lifecycle ---
|
|
158
|
+
formResetCallback() {
|
|
159
|
+
// Restoring `value` re-selects the default segment via `updated()`.
|
|
160
|
+
this.value = this._defaultFormValue;
|
|
161
|
+
super.formResetCallback();
|
|
162
|
+
}
|
|
163
|
+
formStateRestoreCallback(state, mode) {
|
|
164
|
+
this.value = state;
|
|
165
|
+
super.formStateRestoreCallback(state, mode);
|
|
166
|
+
}
|
|
167
|
+
// --- Setup / Teardown ---
|
|
168
|
+
_setup() {
|
|
169
|
+
this._buttons = Array.from(this.querySelectorAll('button'));
|
|
170
|
+
if (this._buttons.length < 2)
|
|
171
|
+
return;
|
|
172
|
+
this._initialized = true;
|
|
173
|
+
this.setAttribute('role', 'radiogroup');
|
|
174
|
+
if (this.label)
|
|
175
|
+
this.setAttribute('aria-label', this.label);
|
|
176
|
+
const activeIndex = this._resolveActiveIndex();
|
|
177
|
+
for (let i = 0; i < this._buttons.length; i++) {
|
|
178
|
+
const btn = this._buttons[i];
|
|
179
|
+
// Force type=button so a segment never submits the enclosing form.
|
|
180
|
+
btn.type = 'button';
|
|
181
|
+
btn.setAttribute('role', 'radio');
|
|
182
|
+
btn.setAttribute('aria-checked', String(i === activeIndex));
|
|
183
|
+
btn.setAttribute('tabindex', i === activeIndex ? '0' : '-1');
|
|
184
|
+
// A segment with no visible text is icon-only: square it and require an
|
|
185
|
+
// accessible name (the icon itself is decorative to assistive tech).
|
|
186
|
+
if ((btn.textContent ?? '').trim() === '') {
|
|
187
|
+
btn.setAttribute('data-icon-only', '');
|
|
188
|
+
if (!btn.hasAttribute('aria-label') && !btn.hasAttribute('aria-labelledby')) {
|
|
189
|
+
// eslint-disable-next-line no-console
|
|
190
|
+
console.warn(`<${this.localName}>: icon-only segment #${i} has no accessible name; add aria-label.`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
this.addEventListener('click', this._onClick);
|
|
195
|
+
this.addEventListener('keydown', this._onKeyDown);
|
|
196
|
+
// Reflect a whole-control `disabled` onto the segments (aria + tab order).
|
|
197
|
+
this._applyDisabledState();
|
|
198
|
+
// Canonicalize `value` to the active segment now that roles are set, and
|
|
199
|
+
// seed the form value (the value a form reset restores to).
|
|
200
|
+
this._syncValueFromIndex(activeIndex);
|
|
201
|
+
this._defaultFormValue = this.value;
|
|
202
|
+
this._syncFormValue(this.value);
|
|
203
|
+
// Initial pill position. Synchronous first (offset reads force layout),
|
|
204
|
+
// then a ResizeObserver keeps it correct across hidden→visible transitions
|
|
205
|
+
// and size changes — rAF is suspended in hidden documents, so it must not
|
|
206
|
+
// be the only scheduling mechanism.
|
|
207
|
+
this._updateIndicator();
|
|
208
|
+
this._resizeObserver = new ResizeObserver(() => this._updateIndicator());
|
|
209
|
+
this._resizeObserver.observe(this);
|
|
210
|
+
}
|
|
211
|
+
_teardown() {
|
|
212
|
+
this.removeEventListener('click', this._onClick);
|
|
213
|
+
this.removeEventListener('keydown', this._onKeyDown);
|
|
214
|
+
this._resizeObserver?.disconnect();
|
|
215
|
+
this._resizeObserver = null;
|
|
216
|
+
this._initialized = false;
|
|
217
|
+
}
|
|
218
|
+
// --- Selection ---
|
|
219
|
+
/** Resolve the selected index from `value`, else an authored `aria-checked`, else 0. */
|
|
220
|
+
_resolveActiveIndex() {
|
|
221
|
+
const { _buttons } = this;
|
|
222
|
+
if (this.value !== '') {
|
|
223
|
+
const byValue = _buttons.findIndex((b) => (b.getAttribute('value') ?? '') === this.value);
|
|
224
|
+
if (byValue >= 0)
|
|
225
|
+
return byValue;
|
|
226
|
+
const asIndex = Number(this.value);
|
|
227
|
+
if (Number.isInteger(asIndex) && asIndex >= 0 && asIndex < _buttons.length)
|
|
228
|
+
return asIndex;
|
|
229
|
+
}
|
|
230
|
+
const checked = _buttons.findIndex((b) => b.getAttribute('aria-checked') === 'true');
|
|
231
|
+
if (checked >= 0)
|
|
232
|
+
return checked;
|
|
233
|
+
// No explicit selection: default to the first enabled segment (else 0).
|
|
234
|
+
const firstEnabled = _buttons.findIndex((b) => !this._isDisabled(b));
|
|
235
|
+
return firstEnabled >= 0 ? firstEnabled : 0;
|
|
236
|
+
}
|
|
237
|
+
_syncValueFromIndex(index) {
|
|
238
|
+
this.value = this._buttons[index]?.getAttribute('value') ?? String(index);
|
|
239
|
+
}
|
|
240
|
+
_select(index, emitEvent = true) {
|
|
241
|
+
if (index < 0 || index >= this._buttons.length)
|
|
242
|
+
return;
|
|
243
|
+
// Whether this is an actual change — re-selecting the current segment must
|
|
244
|
+
// not re-fire `change` (matches native radio / `<select>`).
|
|
245
|
+
const changed = this._buttons[index].getAttribute('aria-checked') !== 'true';
|
|
246
|
+
for (let i = 0; i < this._buttons.length; i++) {
|
|
247
|
+
const isActive = i === index;
|
|
248
|
+
this._buttons[i].setAttribute('aria-checked', String(isActive));
|
|
249
|
+
// Disabled control keeps every segment out of the tab order.
|
|
250
|
+
this._buttons[i].setAttribute('tabindex', isActive && !this.disabled ? '0' : '-1');
|
|
251
|
+
}
|
|
252
|
+
this._syncValueFromIndex(index);
|
|
253
|
+
this._syncFormValue(this.value);
|
|
254
|
+
this._updateIndicator();
|
|
255
|
+
if (emitEvent && changed) {
|
|
256
|
+
this.hasInteracted = true;
|
|
257
|
+
this.dispatchEvent(new SegmentChangeEvent(this.value, index));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/** Index of the next non-disabled segment from `from`, moving by `dir`, wrapping. */
|
|
261
|
+
_nextEnabled(from, dir) {
|
|
262
|
+
const len = this._buttons.length;
|
|
263
|
+
for (let step = 1; step <= len; step++) {
|
|
264
|
+
const i = (from + dir * step + len * step) % len;
|
|
265
|
+
if (!this._isDisabled(this._buttons[i]))
|
|
266
|
+
return i;
|
|
267
|
+
}
|
|
268
|
+
return from;
|
|
269
|
+
}
|
|
270
|
+
_isDisabled(btn) {
|
|
271
|
+
return btn.disabled || btn.getAttribute('aria-disabled') === 'true';
|
|
272
|
+
}
|
|
273
|
+
/** Reflect the host `disabled` state onto the segments (aria + tab order). */
|
|
274
|
+
_applyDisabledState() {
|
|
275
|
+
for (const btn of this._buttons) {
|
|
276
|
+
if (this.disabled) {
|
|
277
|
+
btn.setAttribute('aria-disabled', 'true');
|
|
278
|
+
btn.setAttribute('tabindex', '-1');
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
btn.removeAttribute('aria-disabled');
|
|
282
|
+
// Restore the roving tabindex (only the selected segment is tabbable).
|
|
283
|
+
btn.setAttribute('tabindex', btn.getAttribute('aria-checked') === 'true' ? '0' : '-1');
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// --- Indicator ---
|
|
288
|
+
_updateIndicator() {
|
|
289
|
+
const active = this._buttons.find((b) => b.getAttribute('aria-checked') === 'true');
|
|
290
|
+
if (!active)
|
|
291
|
+
return;
|
|
292
|
+
this.style.setProperty('--_indicator-left', `${active.offsetLeft}px`);
|
|
293
|
+
this.style.setProperty('--_indicator-width', `${active.offsetWidth}px`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
__decorate([
|
|
297
|
+
property({ reflect: true })
|
|
298
|
+
], SegmentedControl.prototype, "value", void 0);
|
|
299
|
+
__decorate([
|
|
300
|
+
property({ reflect: true })
|
|
301
|
+
], SegmentedControl.prototype, "size", void 0);
|
|
302
|
+
__decorate([
|
|
303
|
+
property({ reflect: true })
|
|
304
|
+
], SegmentedControl.prototype, "label", void 0);
|
|
305
|
+
__decorate([
|
|
306
|
+
property({ type: Boolean, reflect: true, attribute: 'full-width' })
|
|
307
|
+
], SegmentedControl.prototype, "fullWidth", void 0);
|