editor-shell 0.20.0 → 0.21.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/dist/panel/index.d.ts +141 -1
- package/dist/panel/index.js +48 -1
- package/dist/panel/index.js.map +1 -1
- package/dist/panel/panel.css +189 -16
- package/package.json +1 -1
package/dist/panel/index.d.ts
CHANGED
|
@@ -62,6 +62,98 @@ interface SettingsTabsProps {
|
|
|
62
62
|
ariaLabel?: string;
|
|
63
63
|
className?: string;
|
|
64
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* WHERE THIS STRIP'S WORDS STOP FITTING — the one thing the shell cannot know
|
|
67
|
+
* about a segmented control, measured by the consumer that owns the words.
|
|
68
|
+
*
|
|
69
|
+
* Both numbers are the width of TEXT ONLY, at the chip's own 12px/600, and
|
|
70
|
+
* neither includes any padding, glyph or gap: those are the chip's, they are
|
|
71
|
+
* named on the panel root, and `panel.css` adds them. That split is the point.
|
|
72
|
+
* A card that re-pads a chip — card 66138 already moved the track's side
|
|
73
|
+
* padding once — moves every threshold with it, instead of leaving each
|
|
74
|
+
* consumer holding a number that went stale silently.
|
|
75
|
+
*
|
|
76
|
+
* MEASURE THEM, do not count letters. The chip's font resolves to SF on a Mac
|
|
77
|
+
* and Segoe UI on Windows, so one table of letter widths would be wrong on most
|
|
78
|
+
* machines; measured against real labels a letter-count guess is out by up to
|
|
79
|
+
* 7%, which is enough to drop a word that fits. Render the strip and read the
|
|
80
|
+
* label spans — `tests/fixtures/segmented-fit.html` does exactly that and
|
|
81
|
+
* prints both numbers.
|
|
82
|
+
*
|
|
83
|
+
* AN OVER-ESTIMATE IS SAFE and an under-estimate is not: too large drops the
|
|
84
|
+
* words a little early, too small draws a segment past the panel edge. If a
|
|
85
|
+
* label is about to change and the number cannot be re-measured today, round it
|
|
86
|
+
* up.
|
|
87
|
+
*/
|
|
88
|
+
interface SettingsSegmentedFit {
|
|
89
|
+
/** The sum of every option label's width, px. Decides where ALL the words go. */
|
|
90
|
+
labelsPx: number;
|
|
91
|
+
/**
|
|
92
|
+
* The WIDEST single option label, px. Decides where the chosen one goes too.
|
|
93
|
+
*
|
|
94
|
+
* The widest and not the current one: the merchant may be on any option, and
|
|
95
|
+
* a strip that fits while "Bold" is chosen and overflows on "Semibold" is a
|
|
96
|
+
* strip that overflows.
|
|
97
|
+
*/
|
|
98
|
+
widestLabelPx: number;
|
|
99
|
+
}
|
|
100
|
+
/** One option in a segmented control. */
|
|
101
|
+
interface SettingsSegmentedOption {
|
|
102
|
+
/** The VALUE this option writes, as `value` and `onChange` name it. */
|
|
103
|
+
id: string;
|
|
104
|
+
/**
|
|
105
|
+
* What the option is CALLED — and its name at every width, exactly as a tab's
|
|
106
|
+
* label is. It rides on the button as `title` and `aria-label` in every
|
|
107
|
+
* state, so a strip reduced to glyphs still names all of its options.
|
|
108
|
+
*/
|
|
109
|
+
label: string;
|
|
110
|
+
/**
|
|
111
|
+
* A 16px glyph from `editor-shell/icons`, so the option can spend less width
|
|
112
|
+
* than its word needs when the panel is dragged narrow.
|
|
113
|
+
*
|
|
114
|
+
* Optional, and the strip is unchanged without it: an option with no glyph
|
|
115
|
+
* has nothing to fall back to, so it keeps its word at every width. Pass
|
|
116
|
+
* glyphs for ALL the options or none of them — half words and half glyphs
|
|
117
|
+
* reads as a rendering fault, not as a decision.
|
|
118
|
+
*/
|
|
119
|
+
icon?: ReactNode;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* A SEGMENTED CONTROL — the tab strip's chip, with the other promise.
|
|
123
|
+
*
|
|
124
|
+
* `SettingsTabs` is a TABLIST: its chips switch a panel, which is what
|
|
125
|
+
* `role="tab"` promises. These chips switch nothing. They write a value and the
|
|
126
|
+
* panel stays where it is, so they are pressed buttons in a group, and the
|
|
127
|
+
* marker is `aria-pressed` — the same one `efficient-shop`'s own `Segmented`
|
|
128
|
+
* has carried since the 2026-08-06 audit.
|
|
129
|
+
*
|
|
130
|
+
* NOT `radiogroup` / `aria-checked`, and the reason is the keyboard rather than
|
|
131
|
+
* the taxonomy. `role="radio"` promises roving focus: one Tab stop for the set,
|
|
132
|
+
* arrows moving between the options and selecting as they go. This is a
|
|
133
|
+
* hook-free controlled leaf — it owns no focus and runs no key handler, and a
|
|
134
|
+
* host cannot add one to a `<button>` it did not render. Radio roles over
|
|
135
|
+
* plain tab-stopped buttons would promise a keyboard that is not there, which
|
|
136
|
+
* is the same defect as `role="tab"` over no panel.
|
|
137
|
+
*/
|
|
138
|
+
interface SettingsSegmentedProps {
|
|
139
|
+
options: SettingsSegmentedOption[];
|
|
140
|
+
/** The `id` of the option currently set. */
|
|
141
|
+
value: string;
|
|
142
|
+
onChange: (next: string) => void;
|
|
143
|
+
/**
|
|
144
|
+
* What the group is called — "Weight", "Alignment". Required: a group of
|
|
145
|
+
* pressed buttons with no name is a set a screen reader cannot introduce.
|
|
146
|
+
*/
|
|
147
|
+
ariaLabel: string;
|
|
148
|
+
/**
|
|
149
|
+
* Where this strip's words stop fitting. Optional, and WITHOUT IT NOTHING
|
|
150
|
+
* DROPS: an unmeasured strip keeps every word at every width, because the
|
|
151
|
+
* only alternative is guessing where to hide one. Give the options glyphs and
|
|
152
|
+
* this too, or the glyphs have nothing to fall back at.
|
|
153
|
+
*/
|
|
154
|
+
fit?: SettingsSegmentedFit;
|
|
155
|
+
className?: string;
|
|
156
|
+
}
|
|
65
157
|
interface SettingsGroupProps {
|
|
66
158
|
/** The group's name — "Items", "Spacing", "Typeface". */
|
|
67
159
|
title: string;
|
|
@@ -269,6 +361,54 @@ interface SettingsDrillProps {
|
|
|
269
361
|
*/
|
|
270
362
|
declare function SettingsTabs({ items, activeId, onSelect, ariaLabel, className, }: SettingsTabsProps): react.JSX.Element;
|
|
271
363
|
|
|
364
|
+
/**
|
|
365
|
+
* A segmented control: one row of chips, one of them pressed.
|
|
366
|
+
*
|
|
367
|
+
* THE SAME CHIP AS `SettingsTabs`, AND A DIFFERENT PROMISE. Everything a
|
|
368
|
+
* merchant sees is shared — the grey track, the raised white chip for the one
|
|
369
|
+
* he is on, the glyph drawn at every width, the word that drops where it stops
|
|
370
|
+
* fitting. `panel.css` dresses both from one rule, so there is no second copy
|
|
371
|
+
* of the chip grammar to drift.
|
|
372
|
+
*
|
|
373
|
+
* What differs is what the control SAYS it does. `SettingsTabs` is a tablist
|
|
374
|
+
* and `role="tab"` promises that pressing a chip switches a panel. These chips
|
|
375
|
+
* switch nothing: they write a value and the panel stays where it is. So this
|
|
376
|
+
* is a group of pressed buttons, and its marker is `aria-pressed` — which is
|
|
377
|
+
* also what the storefront's own `Segmented` has carried since the 2026-08-06
|
|
378
|
+
* audit, so the two editors keep saying the same thing about the same control.
|
|
379
|
+
*
|
|
380
|
+
* That distinction is not decoration. Reach for `SettingsTabs` on a Weight row
|
|
381
|
+
* and a control that is correct today starts announcing "tab, 1 of 4, selected"
|
|
382
|
+
* over a panel that is not there — three times in one part panel. It is the
|
|
383
|
+
* same split the package already made between `SettingsGroup` and
|
|
384
|
+
* `SettingsGroupHead`: same pill, different promise, two components.
|
|
385
|
+
*
|
|
386
|
+
* Controlled: the host owns `value`. No hooks, no browser globals.
|
|
387
|
+
*
|
|
388
|
+
* ── WHERE THE WORD DROPS, AND WHY THE STRIP HAS TO SAY ───────────────────────
|
|
389
|
+
*
|
|
390
|
+
* The tab strip's tiers are keyed on how many tabs there are, which works
|
|
391
|
+
* because the shell knows those four words. It cannot work here. Measured
|
|
392
|
+
* (`tests/fixtures/segmented-fit.html`), Case — Normal / Uppercase / Lowercase
|
|
393
|
+
* — needs 296px for its words and Alignment — Left / Centre / Right — needs
|
|
394
|
+
* 221px. Both are three-option rows, and the panel drags between 181px and
|
|
395
|
+
* 281px, so a single three-option threshold is visibly wrong for one of them.
|
|
396
|
+
* Whether the words fit is a question about the SUM of the labels; the count is
|
|
397
|
+
* only a stand-in, and it stops standing in the moment a second strip arrives.
|
|
398
|
+
*
|
|
399
|
+
* So the strip carries its own measurement. `fit` is the width of its WORDS,
|
|
400
|
+
* nothing else — the chip's padding, glyph and gaps are the shell's, named on
|
|
401
|
+
* the panel root, and `panel.css` adds them. Those three custom properties
|
|
402
|
+
* below are that measurement and the count, handed to the stylesheet, which
|
|
403
|
+
* compares them against the panel's live width in `calc()`. A container query
|
|
404
|
+
* could not: its condition takes a literal length, so it cannot ask a question
|
|
405
|
+
* whose answer is different for every strip.
|
|
406
|
+
*
|
|
407
|
+
* Without `fit` nothing drops — an unmeasured strip keeps every word at every
|
|
408
|
+
* width, which is exactly what a strip with no glyphs already does.
|
|
409
|
+
*/
|
|
410
|
+
declare function SettingsSegmented({ options, value, onChange, ariaLabel, fit, className, }: SettingsSegmentedProps): react.JSX.Element;
|
|
411
|
+
|
|
272
412
|
/**
|
|
273
413
|
* A group of settings under a soft pill row that folds with − / +.
|
|
274
414
|
*
|
|
@@ -393,4 +533,4 @@ declare function SettingsSwitch({ checked, onChange, disabled, id, ariaLabel, ar
|
|
|
393
533
|
*/
|
|
394
534
|
declare function SettingsDrill({ name, preview, offPage, status, onOpen, id, className, }: SettingsDrillProps): react.JSX.Element;
|
|
395
535
|
|
|
396
|
-
export { SettingsDrill, type SettingsDrillProps, SettingsField, type SettingsFieldProps, SettingsGroup, SettingsGroupHead, type SettingsGroupHeadProps, type SettingsGroupProps, SettingsSwitch, type SettingsSwitchBaseProps, type SettingsSwitchLabel, type SettingsSwitchProps, type SettingsTabItem, SettingsTabs, type SettingsTabsProps };
|
|
536
|
+
export { SettingsDrill, type SettingsDrillProps, SettingsField, type SettingsFieldProps, SettingsGroup, SettingsGroupHead, type SettingsGroupHeadProps, type SettingsGroupProps, SettingsSegmented, type SettingsSegmentedFit, type SettingsSegmentedOption, type SettingsSegmentedProps, SettingsSwitch, type SettingsSwitchBaseProps, type SettingsSwitchLabel, type SettingsSwitchProps, type SettingsTabItem, SettingsTabs, type SettingsTabsProps };
|
package/dist/panel/index.js
CHANGED
|
@@ -47,6 +47,53 @@ function SettingsTabs({
|
|
|
47
47
|
) })
|
|
48
48
|
);
|
|
49
49
|
}
|
|
50
|
+
function SettingsSegmented({
|
|
51
|
+
options,
|
|
52
|
+
value,
|
|
53
|
+
onChange,
|
|
54
|
+
ariaLabel,
|
|
55
|
+
fit,
|
|
56
|
+
className
|
|
57
|
+
}) {
|
|
58
|
+
const measured = fit && {
|
|
59
|
+
["--es-seg-count"]: options.length,
|
|
60
|
+
["--es-seg-labels"]: `${fit.labelsPx}px`,
|
|
61
|
+
["--es-seg-widest"]: `${fit.widestLabelPx}px`
|
|
62
|
+
};
|
|
63
|
+
return (
|
|
64
|
+
// The container context is the SHELL's, not a host's to remember — a panel
|
|
65
|
+
// that forgot to declare one would silently pin the strip to one state.
|
|
66
|
+
/* @__PURE__ */ jsx("div", { className: "es-segs-fit", children: /* @__PURE__ */ jsx(
|
|
67
|
+
"div",
|
|
68
|
+
{
|
|
69
|
+
role: "group",
|
|
70
|
+
"aria-label": ariaLabel,
|
|
71
|
+
className: `es-segs${measured ? " is-measured" : ""}${className ? ` ${className}` : ""}`,
|
|
72
|
+
style: measured,
|
|
73
|
+
children: options.map((option) => {
|
|
74
|
+
const pressed = option.id === value;
|
|
75
|
+
const cls = `es-seg${option.icon ? " has-icon" : ""}${pressed ? " is-active" : ""}`;
|
|
76
|
+
return /* @__PURE__ */ jsxs(
|
|
77
|
+
"button",
|
|
78
|
+
{
|
|
79
|
+
type: "button",
|
|
80
|
+
title: option.label,
|
|
81
|
+
"aria-label": option.label,
|
|
82
|
+
"aria-pressed": pressed,
|
|
83
|
+
className: cls,
|
|
84
|
+
onClick: () => onChange(option.id),
|
|
85
|
+
children: [
|
|
86
|
+
option.icon ? /* @__PURE__ */ jsx("span", { className: "es-seg-icon", "aria-hidden": "true", children: option.icon }) : null,
|
|
87
|
+
/* @__PURE__ */ jsx("span", { className: "es-seg-word", children: option.label })
|
|
88
|
+
]
|
|
89
|
+
},
|
|
90
|
+
option.id
|
|
91
|
+
);
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
) })
|
|
95
|
+
);
|
|
96
|
+
}
|
|
50
97
|
function SettingsGroup({
|
|
51
98
|
title,
|
|
52
99
|
open,
|
|
@@ -185,6 +232,6 @@ function SettingsDrill({
|
|
|
185
232
|
);
|
|
186
233
|
}
|
|
187
234
|
|
|
188
|
-
export { SettingsDrill, SettingsField, SettingsGroup, SettingsGroupHead, SettingsSwitch, SettingsTabs };
|
|
235
|
+
export { SettingsDrill, SettingsField, SettingsGroup, SettingsGroupHead, SettingsSegmented, SettingsSwitch, SettingsTabs };
|
|
189
236
|
//# sourceMappingURL=index.js.map
|
|
190
237
|
//# sourceMappingURL=index.js.map
|
package/dist/panel/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/panel/SettingsTabs.tsx","../../src/panel/SettingsGroup.tsx","../../src/panel/SettingsGroupHead.tsx","../../src/panel/SettingsSwitch.tsx","../../src/panel/SettingsField.tsx","../../src/panel/SettingsDrill.tsx"],"names":["jsxs","jsx"],"mappings":"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","file":"index.js","sourcesContent":["import type { SettingsTabsProps } from './types';\n\n/**\n * The tab strip under the panel head.\n *\n * It replaces the old stack of fold headers a merchant had to open and hunt\n * through: the buckets a section actually has become tabs, in one fixed order,\n * so the panel keeps the same shape on every section. A section shows only the\n * tabs it has — pass three items and three tabs render.\n *\n * Controlled: the host decides `activeId`. No hooks, no browser globals.\n *\n * A tab can be WIRED to what it shows (`panelId` / `htmlId` on the item). That\n * is not decoration: `role=\"tab\"` promises a panel, and a tab strip that names\n * none announces \"tab, 1 of 4, selected\" over nothing. The shell cannot supply\n * those ids — only the host knows the element the settings land in — but until\n * card 8802.d it could not ACCEPT them either, so no host could fix it. A tab\n * with nothing to point at renders no attribute at all; an empty\n * `aria-controls` is worse than a missing one, because it names an element\n * that does not exist instead of admitting there is none.\n *\n * A SEGMENTED CONTROL (card 66138). The strip is a grey track and the chosen\n * bucket is a raised white chip on it. Lewis, looking at a real panel: the tab\n * he is NOT on does not invite a click — it does not even look like a button.\n * The track is the answer to that, because it makes every segment look\n * pressable, where a mark under the chosen tab only ever made the chosen tab\n * louder. It replaces the underline strip and card 8811's tint ground; see the\n * long note on `.es-tab.is-active` in `panel.css`.\n *\n * A segment takes the width its OWN label needs and shares what is left over,\n * rather than every segment taking the width of the widest label. Equal\n * segments were measured and cost too much: three of them need a 267px track\n * and four need 355px, against the 281px the shop's default panel gives.\n *\n * THE WORD DROPS WHERE IT STOPS FITTING. Natural widths fit two and three\n * buckets across the whole panel-drag band; they do not fit four, where the\n * last segment ends 39px past the panel edge. So given an `icon`, a segment can\n * spend less width instead of more — the word goes and the glyph stays.\n *\n * Where that happens depends on HOW MANY buckets a section has, because with\n * natural widths the question is about the sum of the labels:\n *\n * two buckets both words, at every width the panel drags to.\n * three buckets words down to 240px, then glyph plus the chosen word.\n * four buckets glyph plus the chosen word under 320px; glyphs under 200px.\n *\n * Which state is a CSS decision, taken against the PANEL's width — see the\n * `@container` blocks in `panel.css`, and the wrapper below that gives them\n * something to measure. React cannot know the answer at render time, and it\n * does not need to: BOTH parts are always in the markup, and CSS shows the one\n * that fits. The word is only ever hidden, never dropped, and the label rides\n * along as `title` and `aria-label` on every tab in every state, so the glyph\n * never has to speak for the tab.\n *\n * A tab with no icon is untouched at every width — it has nothing to fall back\n * to, so it keeps its word.\n */\nexport function SettingsTabs({\n items,\n activeId,\n onSelect,\n ariaLabel = 'Settings',\n className,\n}: SettingsTabsProps) {\n return (\n // The container context is the SHELL's, not a host's to remember. A panel\n // that forgot to declare one would silently pin the strip to one tier.\n <div className=\"es-tabs-fit\">\n <div\n role=\"tablist\"\n aria-label={ariaLabel}\n aria-orientation=\"horizontal\"\n className={className ? `es-tabs ${className}` : 'es-tabs'}\n >\n {items.map((item) => {\n const active = item.id === activeId;\n const cls = `es-tab${item.icon ? ' has-icon' : ''}${active ? ' is-active' : ''}`;\n return (\n <button\n key={item.id}\n type=\"button\"\n role=\"tab\"\n id={item.htmlId}\n title={item.label}\n aria-label={item.label}\n aria-selected={active}\n aria-controls={item.panelId}\n className={cls}\n onClick={() => onSelect(item.id)}\n >\n {item.icon ? (\n <span className=\"es-tab-icon\" aria-hidden=\"true\">\n {item.icon}\n </span>\n ) : null}\n <span className=\"es-tab-word\">{item.label}</span>\n {typeof item.badge === 'number' && item.badge > 0 ? (\n <span className=\"es-tab-badge\">{item.badge}</span>\n ) : null}\n </button>\n );\n })}\n </div>\n </div>\n );\n}\n","import type { SettingsGroupProps } from './types';\n\n/**\n * A group of settings under a soft pill row that folds with − / +.\n *\n * The sign is drawn by `panel.css` from `aria-expanded`, so the open state has\n * exactly one home (the attribute assistive tech already reads) instead of a\n * second copy in the markup.\n *\n * `count` is the reason a fold here is safe: a folded group that holds changes\n * still says so on its pill, so nothing a merchant changed can hide.\n */\nexport function SettingsGroup({\n title,\n open,\n onToggle,\n count,\n id,\n children,\n className,\n}: SettingsGroupProps) {\n const bodyId = id ? `${id}-body` : undefined;\n return (\n <div className={className ? `es-group ${className}` : 'es-group'}>\n <button\n type=\"button\"\n id={id}\n aria-expanded={open}\n aria-controls={bodyId}\n className=\"es-group-head\"\n onClick={() => onToggle(!open)}\n >\n {title}\n {typeof count === 'number' && count > 0 ? (\n <span className=\"es-group-count\">{count}</span>\n ) : null}\n <span className=\"es-group-sign\" aria-hidden=\"true\" />\n </button>\n <div id={bodyId} className=\"es-group-body\" hidden={!open}>\n {children}\n </div>\n </div>\n );\n}\n","import type { SettingsGroupHeadProps } from './types';\n\n/**\n * A group heading with NO group under it — the pill on its own.\n *\n * It exists because Puck lays the inspector out as a FLAT sibling list: a field\n * renders next to the heading above it, never inside it, so nothing here can\n * wrap the rows that follow. Heading a run of settings is therefore a job for a\n * component that heads and nothing else.\n *\n * `SettingsGroup` cannot do that job. It always renders its body div, and its\n * pill is always a <button> carrying `aria-expanded`, `aria-controls` and the\n * − / + sign. Used header-only it would draw a control that claims to expand\n * something, does nothing when clicked, and tells a screen reader the same lie.\n * A component that folds and a component that titles are two different things,\n * and this is the one that titles.\n *\n * So it is a HEADING, not a control: a real <h3> (the level is the host's, since\n * only the host knows the page outline around it) with no role bolted on, no\n * expanded state, no sign, no body. In a flat list the heading list is the only\n * structure a screen reader has left to navigate by, which is the whole reason\n * this renders a heading element rather than a styled <div>.\n *\n * It wears `.es-group-head` — the same pill as a real group's — so a section\n * that folds and a section that does not read as the same panel. `panel.css`\n * scopes the cursor and the hover tint to `button.es-group-head`, so the\n * heading does not offer a press it cannot honour.\n *\n * NO `count`. A folded group needs one because a fold can hide a change; a\n * heading folds nothing, so nothing can hide behind it.\n */\nexport function SettingsGroupHead({\n title,\n level = 3,\n id,\n className,\n}: SettingsGroupHeadProps) {\n const Tag = `h${level}` as const;\n return (\n <Tag id={id} className={className ? `es-group-head ${className}` : 'es-group-head'}>\n {title}\n </Tag>\n );\n}\n","import type { SettingsSwitchProps } from './types';\n\n/**\n * The yes-or-no control — ONE shape, both editors (card 8801).\n *\n * Point 5 of the approved settings design says one switch style everywhere.\n * Before this, the storefront editor drew a tick box and the campaign designer\n * drew a green switch, so a merchant met two shapes for the same question and\n * had to learn the control twice. Both now import this file, which is the only\n * thing that stops them drifting apart again.\n *\n * It is a real <button>, and that is the whole keyboard story: a button is\n * activated by Space and by Enter in every browser, and that activation IS a\n * click — so the one `onClick` below serves mouse and keyboard alike. A div\n * wearing `role=\"switch\"` would need a key handler of our own. Do not add one.\n * `type=\"button\"` keeps it from submitting a form it happens to sit in.\n *\n * It cannot exist without a NAME. `SettingsSwitchProps` demands `ariaLabel` or\n * `ariaLabelledBy` and forbids both at once, so an unnamed switch — which a\n * screen reader reads out as \"switch, on\" and nothing more — is a compile error\n * instead of something a docstring asks you to remember.\n *\n * ONE-WAY DEPENDENCY. `SettingsField` imports this module so it can recognise a\n * switch in its `action` slot and hand it the field's label as a name. This\n * module must NEVER import `SettingsField` back — that would close a cycle\n * between two siblings in the same folder. It imports `./types` and nothing\n * else, and `tests/settings-switch.test.tsx` (o) keeps it that way.\n *\n * CONTROLLED and hook-free like the other three: `checked` comes from the host\n * and the switch only ever ASKS to be flipped. That is what keeps this module a\n * directive-free leaf a Next-16 server component can import.\n */\nexport function SettingsSwitch({\n checked,\n onChange,\n disabled,\n id,\n ariaLabel,\n ariaLabelledBy,\n className,\n}: SettingsSwitchProps) {\n return (\n <button\n type=\"button\"\n role=\"switch\"\n id={id}\n aria-checked={checked}\n aria-label={ariaLabel}\n aria-labelledby={ariaLabelledBy}\n disabled={disabled}\n className={className ? `es-switch ${className}` : 'es-switch'}\n onClick={() => onChange(!checked)}\n />\n );\n}\n","import { cloneElement, isValidElement } from 'react';\nimport type { ReactElement, ReactNode } from 'react';\n\nimport { SettingsSwitch } from './SettingsSwitch';\nimport type { SettingsFieldProps } from './types';\n\n/**\n * Does this slot hold a control?\n *\n * The rule is ELEMENT-OR-NOTHING, deliberately not a list of falsy values: a\n * list rots, and the next odd value nobody thought of walks straight through.\n * `{flag && <Slider/>}` yields `false`, `{count && <Slider/>}` yields `0` when\n * the count is zero — and React RENDERS that zero, a stray 0 sitting in the\n * panel where a control belongs. A bare string or number in a control slot is a\n * caller mistake in every case any of us can name.\n *\n * Arrays are walked rather than trusted: `Array.isArray([])` is true, so an\n * empty array — or one holding only `false` and `null`, which is what a list of\n * conditional controls collapses to — would otherwise draw the empty row this\n * check exists to prevent.\n */\nfunction hasControlIn(slot: ReactNode): boolean {\n if (Array.isArray(slot)) return slot.some(hasControlIn);\n return isValidElement(slot);\n}\n\n/** What a switch is named by. Read off an unknown element, so it stays loose. */\ninterface SwitchName {\n ariaLabel?: string;\n ariaLabelledBy?: string;\n}\n\n/**\n * Give a switch in the `action` slot the field's own label as its name.\n *\n * A switch carries no text, and `htmlFor` cannot bridge a <label> to a\n * <button>, so in a boolean row nothing connects the visible label to the\n * control unless something does it explicitly. Doing it HERE means the common\n * case is right with the caller saying nothing: the accessible name and the\n * visible name are the same string and cannot drift apart.\n *\n * It only ever fills a gap. A switch that names itself keeps its own name, and\n * anything that is not a switch — a reset dot, a badge — is left alone, since\n * labelling those with the field's name would be worse than not labelling them.\n */\nfunction nameSwitch(action: ReactNode, label: string, labelId?: string): ReactNode {\n if (!isValidElement(action) || action.type !== SettingsSwitch) return action;\n\n const named = action.props as SwitchName;\n if (named.ariaLabel || named.ariaLabelledBy) return action;\n\n return cloneElement(\n action as ReactElement<SwitchName>,\n labelId ? { ariaLabelledBy: labelId } : { ariaLabel: label },\n );\n}\n\n/**\n * One setting: a label row, then its control.\n *\n * The row is stacked rather than side-by-side so the control gets the panel's\n * full width — a 308px panel cannot afford a label column AND a usable slider.\n * The label reads small and quiet; the VALUE sits at the end of the same line in\n * the text colour, which is what a merchant scans for. That contrast is the\n * point: before this, label and value looked alike and the panel read as noise.\n *\n * `hint` is where a clause-long explanation goes. A label is a name, never a\n * sentence.\n *\n * A BOOLEAN row is the one exception to \"control on its own line\" (card 8801,\n * Option A, approved). A switch is small and it is the whole control, so it\n * rides the label line in the `action` slot and the row costs one line instead\n * of two — which is what lets a 308px panel show four settings where it used to\n * show three. That is the only reason `children` is optional: a field with no\n * children draws no control row, and its label line IS the row.\n *\n * A field with no children AND no action has no control on either slot. That is\n * a mistake at the call site, so it renders NOTHING — an empty row would hide\n * the mistake behind 20px of blank panel.\n */\nexport function SettingsField({\n label,\n labelId,\n icon,\n value,\n hint,\n changed,\n htmlFor,\n action,\n children,\n className,\n}: SettingsFieldProps) {\n const hasControl = hasControlIn(children);\n const hasAction = hasControlIn(action);\n if (!hasControl && !hasAction) return null;\n\n const classes = ['es-field'];\n if (!hasControl) classes.push('is-inline');\n if (changed) classes.push('is-changed');\n if (className) classes.push(className);\n\n const labelBody = (\n <>\n {icon ? (\n <span className=\"es-field-icon\" aria-hidden=\"true\">\n {icon}\n </span>\n ) : null}\n <span className=\"es-field-label\" id={labelId}>\n {label}\n </span>\n </>\n );\n\n return (\n <div className={classes.join(' ')}>\n <div className=\"es-field-head\">\n {htmlFor ? (\n <label className=\"es-field-name\" htmlFor={htmlFor}>\n {labelBody}\n </label>\n ) : (\n <span className=\"es-field-name\">{labelBody}</span>\n )}\n {value !== undefined && value !== null ? (\n <span className=\"es-field-value\">{value}</span>\n ) : null}\n {hasAction ? nameSwitch(action, label, labelId) : null}\n </div>\n {hasControl ? <div className=\"es-field-ctl\">{children}</div> : null}\n {hint ? <p className=\"es-field-hint\">{hint}</p> : null}\n </div>\n );\n}\n","import type { SettingsDrillProps } from './types';\n\n/**\n * A DRILL-IN ROW — a name, a one-line preview, and a chevron (card 8819).\n *\n * The storefront editor's text editing becomes a panel drill-down: the section\n * screen no longer holds a text box for every part; it LISTS the parts as these\n * rows, and pressing one takes the inspector down a level to that part's own\n * editor. A part the eye hid — or one that never draws on the page at all, like\n * a `Slide name` — has no words on the canvas to click, so this list is the only\n * way back to it. That is why the list exists, and why the row is the shell's,\n * not the shop's: the campaign designer draws the same list.\n *\n * IT IS ONE BUTTON, not a control beside some text. The name and the preview are\n * the button's own content, so they ARE its accessible name — a chevron floating\n * next to unlabelled text would be a press a screen reader could not describe.\n * The chevron is `aria-hidden` (pure direction); the status slot is NOT, because\n * its meaning is the host's to name — it passes an icon carrying its own label\n * (a `title`, a visually-hidden word), and hiding the slot would bury that.\n *\n * CONTROLLED and hook-free like the other four primitives: it owns no open state.\n * Pressing it calls `onOpen`, and the host decides what drilling down means —\n * which keeps this a directive-free leaf a Next-16 server component can import.\n */\nexport function SettingsDrill({\n name,\n preview,\n offPage,\n status,\n onOpen,\n id,\n className,\n}: SettingsDrillProps) {\n const hasPreview = preview !== undefined && preview !== null;\n return (\n <button\n type=\"button\"\n id={id}\n className={className ? `es-drill ${className}` : 'es-drill'}\n onClick={onOpen}\n >\n <span className=\"es-drill-text\">\n <span className=\"es-drill-name\">{name}</span>\n {hasPreview ? (\n <span className={offPage ? 'es-drill-preview is-offpage' : 'es-drill-preview'}>\n {preview}\n </span>\n ) : null}\n </span>\n {status ? <span className=\"es-drill-status\">{status}</span> : null}\n <span className=\"es-drill-chev\" aria-hidden=\"true\" />\n </button>\n );\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/panel/SettingsTabs.tsx","../../src/panel/SettingsSegmented.tsx","../../src/panel/SettingsGroup.tsx","../../src/panel/SettingsGroupHead.tsx","../../src/panel/SettingsSwitch.tsx","../../src/panel/SettingsField.tsx","../../src/panel/SettingsDrill.tsx"],"names":["jsx","jsxs"],"mappings":"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","file":"index.js","sourcesContent":["import type { SettingsTabsProps } from './types';\n\n/**\n * The tab strip under the panel head.\n *\n * It replaces the old stack of fold headers a merchant had to open and hunt\n * through: the buckets a section actually has become tabs, in one fixed order,\n * so the panel keeps the same shape on every section. A section shows only the\n * tabs it has — pass three items and three tabs render.\n *\n * Controlled: the host decides `activeId`. No hooks, no browser globals.\n *\n * A tab can be WIRED to what it shows (`panelId` / `htmlId` on the item). That\n * is not decoration: `role=\"tab\"` promises a panel, and a tab strip that names\n * none announces \"tab, 1 of 4, selected\" over nothing. The shell cannot supply\n * those ids — only the host knows the element the settings land in — but until\n * card 8802.d it could not ACCEPT them either, so no host could fix it. A tab\n * with nothing to point at renders no attribute at all; an empty\n * `aria-controls` is worse than a missing one, because it names an element\n * that does not exist instead of admitting there is none.\n *\n * A SEGMENTED CONTROL (card 66138). The strip is a grey track and the chosen\n * bucket is a raised white chip on it. Lewis, looking at a real panel: the tab\n * he is NOT on does not invite a click — it does not even look like a button.\n * The track is the answer to that, because it makes every segment look\n * pressable, where a mark under the chosen tab only ever made the chosen tab\n * louder. It replaces the underline strip and card 8811's tint ground; see the\n * long note on `.es-tab.is-active` in `panel.css`.\n *\n * A segment takes the width its OWN label needs and shares what is left over,\n * rather than every segment taking the width of the widest label. Equal\n * segments were measured and cost too much: three of them need a 267px track\n * and four need 355px, against the 281px the shop's default panel gives.\n *\n * THE WORD DROPS WHERE IT STOPS FITTING. Natural widths fit two and three\n * buckets across the whole panel-drag band; they do not fit four, where the\n * last segment ends 39px past the panel edge. So given an `icon`, a segment can\n * spend less width instead of more — the word goes and the glyph stays.\n *\n * Where that happens depends on HOW MANY buckets a section has, because with\n * natural widths the question is about the sum of the labels:\n *\n * two buckets both words, at every width the panel drags to.\n * three buckets words down to 240px, then glyph plus the chosen word.\n * four buckets glyph plus the chosen word under 320px; glyphs under 200px.\n *\n * Which state is a CSS decision, taken against the PANEL's width — see the\n * `@container` blocks in `panel.css`, and the wrapper below that gives them\n * something to measure. React cannot know the answer at render time, and it\n * does not need to: BOTH parts are always in the markup, and CSS shows the one\n * that fits. The word is only ever hidden, never dropped, and the label rides\n * along as `title` and `aria-label` on every tab in every state, so the glyph\n * never has to speak for the tab.\n *\n * A tab with no icon is untouched at every width — it has nothing to fall back\n * to, so it keeps its word.\n */\nexport function SettingsTabs({\n items,\n activeId,\n onSelect,\n ariaLabel = 'Settings',\n className,\n}: SettingsTabsProps) {\n return (\n // The container context is the SHELL's, not a host's to remember. A panel\n // that forgot to declare one would silently pin the strip to one tier.\n <div className=\"es-tabs-fit\">\n <div\n role=\"tablist\"\n aria-label={ariaLabel}\n aria-orientation=\"horizontal\"\n className={className ? `es-tabs ${className}` : 'es-tabs'}\n >\n {items.map((item) => {\n const active = item.id === activeId;\n const cls = `es-tab${item.icon ? ' has-icon' : ''}${active ? ' is-active' : ''}`;\n return (\n <button\n key={item.id}\n type=\"button\"\n role=\"tab\"\n id={item.htmlId}\n title={item.label}\n aria-label={item.label}\n aria-selected={active}\n aria-controls={item.panelId}\n className={cls}\n onClick={() => onSelect(item.id)}\n >\n {item.icon ? (\n <span className=\"es-tab-icon\" aria-hidden=\"true\">\n {item.icon}\n </span>\n ) : null}\n <span className=\"es-tab-word\">{item.label}</span>\n {typeof item.badge === 'number' && item.badge > 0 ? (\n <span className=\"es-tab-badge\">{item.badge}</span>\n ) : null}\n </button>\n );\n })}\n </div>\n </div>\n );\n}\n","import type { CSSProperties } from 'react';\nimport type { SettingsSegmentedProps } from './types';\n\n/**\n * A segmented control: one row of chips, one of them pressed.\n *\n * THE SAME CHIP AS `SettingsTabs`, AND A DIFFERENT PROMISE. Everything a\n * merchant sees is shared — the grey track, the raised white chip for the one\n * he is on, the glyph drawn at every width, the word that drops where it stops\n * fitting. `panel.css` dresses both from one rule, so there is no second copy\n * of the chip grammar to drift.\n *\n * What differs is what the control SAYS it does. `SettingsTabs` is a tablist\n * and `role=\"tab\"` promises that pressing a chip switches a panel. These chips\n * switch nothing: they write a value and the panel stays where it is. So this\n * is a group of pressed buttons, and its marker is `aria-pressed` — which is\n * also what the storefront's own `Segmented` has carried since the 2026-08-06\n * audit, so the two editors keep saying the same thing about the same control.\n *\n * That distinction is not decoration. Reach for `SettingsTabs` on a Weight row\n * and a control that is correct today starts announcing \"tab, 1 of 4, selected\"\n * over a panel that is not there — three times in one part panel. It is the\n * same split the package already made between `SettingsGroup` and\n * `SettingsGroupHead`: same pill, different promise, two components.\n *\n * Controlled: the host owns `value`. No hooks, no browser globals.\n *\n * ── WHERE THE WORD DROPS, AND WHY THE STRIP HAS TO SAY ───────────────────────\n *\n * The tab strip's tiers are keyed on how many tabs there are, which works\n * because the shell knows those four words. It cannot work here. Measured\n * (`tests/fixtures/segmented-fit.html`), Case — Normal / Uppercase / Lowercase\n * — needs 296px for its words and Alignment — Left / Centre / Right — needs\n * 221px. Both are three-option rows, and the panel drags between 181px and\n * 281px, so a single three-option threshold is visibly wrong for one of them.\n * Whether the words fit is a question about the SUM of the labels; the count is\n * only a stand-in, and it stops standing in the moment a second strip arrives.\n *\n * So the strip carries its own measurement. `fit` is the width of its WORDS,\n * nothing else — the chip's padding, glyph and gaps are the shell's, named on\n * the panel root, and `panel.css` adds them. Those three custom properties\n * below are that measurement and the count, handed to the stylesheet, which\n * compares them against the panel's live width in `calc()`. A container query\n * could not: its condition takes a literal length, so it cannot ask a question\n * whose answer is different for every strip.\n *\n * Without `fit` nothing drops — an unmeasured strip keeps every word at every\n * width, which is exactly what a strip with no glyphs already does.\n */\nexport function SettingsSegmented({\n options,\n value,\n onChange,\n ariaLabel,\n fit,\n className,\n}: SettingsSegmentedProps) {\n // The measurement, handed to the stylesheet. `is-measured` is what the sheet\n // scopes the threshold arithmetic to, so a strip that declared nothing falls\n // back to no threshold at all rather than to a computed one over zeroes.\n const measured: CSSProperties | undefined = fit && {\n ['--es-seg-count' as string]: options.length,\n ['--es-seg-labels' as string]: `${fit.labelsPx}px`,\n ['--es-seg-widest' as string]: `${fit.widestLabelPx}px`,\n };\n\n return (\n // The container context is the SHELL's, not a host's to remember — a panel\n // that forgot to declare one would silently pin the strip to one state.\n <div className=\"es-segs-fit\">\n <div\n role=\"group\"\n aria-label={ariaLabel}\n className={`es-segs${measured ? ' is-measured' : ''}${className ? ` ${className}` : ''}`}\n style={measured}\n >\n {options.map((option) => {\n const pressed = option.id === value;\n const cls = `es-seg${option.icon ? ' has-icon' : ''}${pressed ? ' is-active' : ''}`;\n return (\n <button\n key={option.id}\n type=\"button\"\n title={option.label}\n aria-label={option.label}\n aria-pressed={pressed}\n className={cls}\n onClick={() => onChange(option.id)}\n >\n {option.icon ? (\n <span className=\"es-seg-icon\" aria-hidden=\"true\">\n {option.icon}\n </span>\n ) : null}\n <span className=\"es-seg-word\">{option.label}</span>\n </button>\n );\n })}\n </div>\n </div>\n );\n}\n","import type { SettingsGroupProps } from './types';\n\n/**\n * A group of settings under a soft pill row that folds with − / +.\n *\n * The sign is drawn by `panel.css` from `aria-expanded`, so the open state has\n * exactly one home (the attribute assistive tech already reads) instead of a\n * second copy in the markup.\n *\n * `count` is the reason a fold here is safe: a folded group that holds changes\n * still says so on its pill, so nothing a merchant changed can hide.\n */\nexport function SettingsGroup({\n title,\n open,\n onToggle,\n count,\n id,\n children,\n className,\n}: SettingsGroupProps) {\n const bodyId = id ? `${id}-body` : undefined;\n return (\n <div className={className ? `es-group ${className}` : 'es-group'}>\n <button\n type=\"button\"\n id={id}\n aria-expanded={open}\n aria-controls={bodyId}\n className=\"es-group-head\"\n onClick={() => onToggle(!open)}\n >\n {title}\n {typeof count === 'number' && count > 0 ? (\n <span className=\"es-group-count\">{count}</span>\n ) : null}\n <span className=\"es-group-sign\" aria-hidden=\"true\" />\n </button>\n <div id={bodyId} className=\"es-group-body\" hidden={!open}>\n {children}\n </div>\n </div>\n );\n}\n","import type { SettingsGroupHeadProps } from './types';\n\n/**\n * A group heading with NO group under it — the pill on its own.\n *\n * It exists because Puck lays the inspector out as a FLAT sibling list: a field\n * renders next to the heading above it, never inside it, so nothing here can\n * wrap the rows that follow. Heading a run of settings is therefore a job for a\n * component that heads and nothing else.\n *\n * `SettingsGroup` cannot do that job. It always renders its body div, and its\n * pill is always a <button> carrying `aria-expanded`, `aria-controls` and the\n * − / + sign. Used header-only it would draw a control that claims to expand\n * something, does nothing when clicked, and tells a screen reader the same lie.\n * A component that folds and a component that titles are two different things,\n * and this is the one that titles.\n *\n * So it is a HEADING, not a control: a real <h3> (the level is the host's, since\n * only the host knows the page outline around it) with no role bolted on, no\n * expanded state, no sign, no body. In a flat list the heading list is the only\n * structure a screen reader has left to navigate by, which is the whole reason\n * this renders a heading element rather than a styled <div>.\n *\n * It wears `.es-group-head` — the same pill as a real group's — so a section\n * that folds and a section that does not read as the same panel. `panel.css`\n * scopes the cursor and the hover tint to `button.es-group-head`, so the\n * heading does not offer a press it cannot honour.\n *\n * NO `count`. A folded group needs one because a fold can hide a change; a\n * heading folds nothing, so nothing can hide behind it.\n */\nexport function SettingsGroupHead({\n title,\n level = 3,\n id,\n className,\n}: SettingsGroupHeadProps) {\n const Tag = `h${level}` as const;\n return (\n <Tag id={id} className={className ? `es-group-head ${className}` : 'es-group-head'}>\n {title}\n </Tag>\n );\n}\n","import type { SettingsSwitchProps } from './types';\n\n/**\n * The yes-or-no control — ONE shape, both editors (card 8801).\n *\n * Point 5 of the approved settings design says one switch style everywhere.\n * Before this, the storefront editor drew a tick box and the campaign designer\n * drew a green switch, so a merchant met two shapes for the same question and\n * had to learn the control twice. Both now import this file, which is the only\n * thing that stops them drifting apart again.\n *\n * It is a real <button>, and that is the whole keyboard story: a button is\n * activated by Space and by Enter in every browser, and that activation IS a\n * click — so the one `onClick` below serves mouse and keyboard alike. A div\n * wearing `role=\"switch\"` would need a key handler of our own. Do not add one.\n * `type=\"button\"` keeps it from submitting a form it happens to sit in.\n *\n * It cannot exist without a NAME. `SettingsSwitchProps` demands `ariaLabel` or\n * `ariaLabelledBy` and forbids both at once, so an unnamed switch — which a\n * screen reader reads out as \"switch, on\" and nothing more — is a compile error\n * instead of something a docstring asks you to remember.\n *\n * ONE-WAY DEPENDENCY. `SettingsField` imports this module so it can recognise a\n * switch in its `action` slot and hand it the field's label as a name. This\n * module must NEVER import `SettingsField` back — that would close a cycle\n * between two siblings in the same folder. It imports `./types` and nothing\n * else, and `tests/settings-switch.test.tsx` (o) keeps it that way.\n *\n * CONTROLLED and hook-free like the other three: `checked` comes from the host\n * and the switch only ever ASKS to be flipped. That is what keeps this module a\n * directive-free leaf a Next-16 server component can import.\n */\nexport function SettingsSwitch({\n checked,\n onChange,\n disabled,\n id,\n ariaLabel,\n ariaLabelledBy,\n className,\n}: SettingsSwitchProps) {\n return (\n <button\n type=\"button\"\n role=\"switch\"\n id={id}\n aria-checked={checked}\n aria-label={ariaLabel}\n aria-labelledby={ariaLabelledBy}\n disabled={disabled}\n className={className ? `es-switch ${className}` : 'es-switch'}\n onClick={() => onChange(!checked)}\n />\n );\n}\n","import { cloneElement, isValidElement } from 'react';\nimport type { ReactElement, ReactNode } from 'react';\n\nimport { SettingsSwitch } from './SettingsSwitch';\nimport type { SettingsFieldProps } from './types';\n\n/**\n * Does this slot hold a control?\n *\n * The rule is ELEMENT-OR-NOTHING, deliberately not a list of falsy values: a\n * list rots, and the next odd value nobody thought of walks straight through.\n * `{flag && <Slider/>}` yields `false`, `{count && <Slider/>}` yields `0` when\n * the count is zero — and React RENDERS that zero, a stray 0 sitting in the\n * panel where a control belongs. A bare string or number in a control slot is a\n * caller mistake in every case any of us can name.\n *\n * Arrays are walked rather than trusted: `Array.isArray([])` is true, so an\n * empty array — or one holding only `false` and `null`, which is what a list of\n * conditional controls collapses to — would otherwise draw the empty row this\n * check exists to prevent.\n */\nfunction hasControlIn(slot: ReactNode): boolean {\n if (Array.isArray(slot)) return slot.some(hasControlIn);\n return isValidElement(slot);\n}\n\n/** What a switch is named by. Read off an unknown element, so it stays loose. */\ninterface SwitchName {\n ariaLabel?: string;\n ariaLabelledBy?: string;\n}\n\n/**\n * Give a switch in the `action` slot the field's own label as its name.\n *\n * A switch carries no text, and `htmlFor` cannot bridge a <label> to a\n * <button>, so in a boolean row nothing connects the visible label to the\n * control unless something does it explicitly. Doing it HERE means the common\n * case is right with the caller saying nothing: the accessible name and the\n * visible name are the same string and cannot drift apart.\n *\n * It only ever fills a gap. A switch that names itself keeps its own name, and\n * anything that is not a switch — a reset dot, a badge — is left alone, since\n * labelling those with the field's name would be worse than not labelling them.\n */\nfunction nameSwitch(action: ReactNode, label: string, labelId?: string): ReactNode {\n if (!isValidElement(action) || action.type !== SettingsSwitch) return action;\n\n const named = action.props as SwitchName;\n if (named.ariaLabel || named.ariaLabelledBy) return action;\n\n return cloneElement(\n action as ReactElement<SwitchName>,\n labelId ? { ariaLabelledBy: labelId } : { ariaLabel: label },\n );\n}\n\n/**\n * One setting: a label row, then its control.\n *\n * The row is stacked rather than side-by-side so the control gets the panel's\n * full width — a 308px panel cannot afford a label column AND a usable slider.\n * The label reads small and quiet; the VALUE sits at the end of the same line in\n * the text colour, which is what a merchant scans for. That contrast is the\n * point: before this, label and value looked alike and the panel read as noise.\n *\n * `hint` is where a clause-long explanation goes. A label is a name, never a\n * sentence.\n *\n * A BOOLEAN row is the one exception to \"control on its own line\" (card 8801,\n * Option A, approved). A switch is small and it is the whole control, so it\n * rides the label line in the `action` slot and the row costs one line instead\n * of two — which is what lets a 308px panel show four settings where it used to\n * show three. That is the only reason `children` is optional: a field with no\n * children draws no control row, and its label line IS the row.\n *\n * A field with no children AND no action has no control on either slot. That is\n * a mistake at the call site, so it renders NOTHING — an empty row would hide\n * the mistake behind 20px of blank panel.\n */\nexport function SettingsField({\n label,\n labelId,\n icon,\n value,\n hint,\n changed,\n htmlFor,\n action,\n children,\n className,\n}: SettingsFieldProps) {\n const hasControl = hasControlIn(children);\n const hasAction = hasControlIn(action);\n if (!hasControl && !hasAction) return null;\n\n const classes = ['es-field'];\n if (!hasControl) classes.push('is-inline');\n if (changed) classes.push('is-changed');\n if (className) classes.push(className);\n\n const labelBody = (\n <>\n {icon ? (\n <span className=\"es-field-icon\" aria-hidden=\"true\">\n {icon}\n </span>\n ) : null}\n <span className=\"es-field-label\" id={labelId}>\n {label}\n </span>\n </>\n );\n\n return (\n <div className={classes.join(' ')}>\n <div className=\"es-field-head\">\n {htmlFor ? (\n <label className=\"es-field-name\" htmlFor={htmlFor}>\n {labelBody}\n </label>\n ) : (\n <span className=\"es-field-name\">{labelBody}</span>\n )}\n {value !== undefined && value !== null ? (\n <span className=\"es-field-value\">{value}</span>\n ) : null}\n {hasAction ? nameSwitch(action, label, labelId) : null}\n </div>\n {hasControl ? <div className=\"es-field-ctl\">{children}</div> : null}\n {hint ? <p className=\"es-field-hint\">{hint}</p> : null}\n </div>\n );\n}\n","import type { SettingsDrillProps } from './types';\n\n/**\n * A DRILL-IN ROW — a name, a one-line preview, and a chevron (card 8819).\n *\n * The storefront editor's text editing becomes a panel drill-down: the section\n * screen no longer holds a text box for every part; it LISTS the parts as these\n * rows, and pressing one takes the inspector down a level to that part's own\n * editor. A part the eye hid — or one that never draws on the page at all, like\n * a `Slide name` — has no words on the canvas to click, so this list is the only\n * way back to it. That is why the list exists, and why the row is the shell's,\n * not the shop's: the campaign designer draws the same list.\n *\n * IT IS ONE BUTTON, not a control beside some text. The name and the preview are\n * the button's own content, so they ARE its accessible name — a chevron floating\n * next to unlabelled text would be a press a screen reader could not describe.\n * The chevron is `aria-hidden` (pure direction); the status slot is NOT, because\n * its meaning is the host's to name — it passes an icon carrying its own label\n * (a `title`, a visually-hidden word), and hiding the slot would bury that.\n *\n * CONTROLLED and hook-free like the other four primitives: it owns no open state.\n * Pressing it calls `onOpen`, and the host decides what drilling down means —\n * which keeps this a directive-free leaf a Next-16 server component can import.\n */\nexport function SettingsDrill({\n name,\n preview,\n offPage,\n status,\n onOpen,\n id,\n className,\n}: SettingsDrillProps) {\n const hasPreview = preview !== undefined && preview !== null;\n return (\n <button\n type=\"button\"\n id={id}\n className={className ? `es-drill ${className}` : 'es-drill'}\n onClick={onOpen}\n >\n <span className=\"es-drill-text\">\n <span className=\"es-drill-name\">{name}</span>\n {hasPreview ? (\n <span className={offPage ? 'es-drill-preview is-offpage' : 'es-drill-preview'}>\n {preview}\n </span>\n ) : null}\n </span>\n {status ? <span className=\"es-drill-status\">{status}</span> : null}\n <span className=\"es-drill-chev\" aria-hidden=\"true\" />\n </button>\n );\n}\n"]}
|
package/dist/panel/panel.css
CHANGED
|
@@ -109,6 +109,29 @@
|
|
|
109
109
|
--es-rhythm-between-tight: 16px;
|
|
110
110
|
/* WITHIN one setting: its label to its own control */
|
|
111
111
|
--es-rhythm-within: 7px;
|
|
112
|
+
|
|
113
|
+
/* ── THE CHIP'S OWN METRICS (card 66248) ───────────────────────────────────
|
|
114
|
+
|
|
115
|
+
These five were literals inside the chip rules until a SECOND component
|
|
116
|
+
had to wear the same chip. They are named for one reason, and it is not
|
|
117
|
+
tidiness: the segmented control's fit thresholds are ARITHMETIC OVER THEM.
|
|
118
|
+
|
|
119
|
+
Its words drop where the words stop fitting, and where that is depends on
|
|
120
|
+
the room the chrome takes before any word is drawn — two chip paddings, a
|
|
121
|
+
glyph, the gap after it, the gaps between chips and the track's own
|
|
122
|
+
padding. Card 66138 has already moved one of these once (the track's side
|
|
123
|
+
padding, 14px → 3px). A threshold holding its own copy of that number would
|
|
124
|
+
have gone stale that day and said nothing. Read from here, it moves with
|
|
125
|
+
the chip.
|
|
126
|
+
|
|
127
|
+
They are DECLARED lengths and not measurements: the chip is drawn from
|
|
128
|
+
these, so they are the source, not a reading of one. The one thing that
|
|
129
|
+
cannot be named here is the width of a WORD — see `.es-segs` below. */
|
|
130
|
+
--es-chip-pad-x: 8px;
|
|
131
|
+
--es-chip-icon: 16px;
|
|
132
|
+
--es-chip-icon-gap: 6px;
|
|
133
|
+
--es-track-pad: 3px;
|
|
134
|
+
--es-track-gap: 3px;
|
|
112
135
|
}
|
|
113
136
|
|
|
114
137
|
/* ── the tab strip: a segmented control ────────────────────────────────────── */
|
|
@@ -126,7 +149,8 @@
|
|
|
126
149
|
The context lives HERE rather than in a host's own panel rule so the shell is
|
|
127
150
|
self-contained: a host that never declares `container-type` would silently
|
|
128
151
|
pin the strip to one state with nothing to see and nothing to fail. */
|
|
129
|
-
.es-tabs-fit
|
|
152
|
+
.es-tabs-fit,
|
|
153
|
+
.es-segs-fit {
|
|
130
154
|
container-type: inline-size;
|
|
131
155
|
flex: none;
|
|
132
156
|
}
|
|
@@ -154,11 +178,12 @@
|
|
|
154
178
|
it is two controls. The guarantee that no bucket is ever out of reach is
|
|
155
179
|
re-made out of different parts: segments that share the leftover space, and
|
|
156
180
|
the drop rules below. */
|
|
157
|
-
.es-tabs
|
|
181
|
+
.es-tabs,
|
|
182
|
+
.es-segs {
|
|
158
183
|
display: flex;
|
|
159
184
|
flex-wrap: nowrap;
|
|
160
|
-
gap:
|
|
161
|
-
padding:
|
|
185
|
+
gap: var(--es-track-gap);
|
|
186
|
+
padding: var(--es-track-pad);
|
|
162
187
|
background: var(--es-track-bg);
|
|
163
188
|
border-radius: var(--es-row-radius);
|
|
164
189
|
flex: none;
|
|
@@ -175,13 +200,14 @@
|
|
|
175
200
|
|
|
176
201
|
`box-sizing` for the same reason the switch sets it: this file assumes no
|
|
177
202
|
reset of the host's. */
|
|
178
|
-
.es-tab
|
|
203
|
+
.es-tab,
|
|
204
|
+
.es-seg {
|
|
179
205
|
box-sizing: border-box;
|
|
180
206
|
flex: 1 1 auto;
|
|
181
207
|
border: 0;
|
|
182
208
|
background: transparent;
|
|
183
209
|
border-radius: 6px;
|
|
184
|
-
padding: 6px
|
|
210
|
+
padding: 6px var(--es-chip-pad-x);
|
|
185
211
|
font: inherit;
|
|
186
212
|
font-size: 12px;
|
|
187
213
|
font-weight: 600;
|
|
@@ -191,7 +217,7 @@
|
|
|
191
217
|
display: inline-flex;
|
|
192
218
|
align-items: center;
|
|
193
219
|
justify-content: center;
|
|
194
|
-
gap:
|
|
220
|
+
gap: var(--es-chip-icon-gap);
|
|
195
221
|
transition: background 0.12s, color 0.12s, box-shadow 0.12s;
|
|
196
222
|
}
|
|
197
223
|
|
|
@@ -199,11 +225,13 @@
|
|
|
199
225
|
grey track sits at about 4.3:1 and misses AA; this is about 8.9:1. The track
|
|
200
226
|
is what says "pressable" now, so the label no longer has to whisper to stay
|
|
201
227
|
out of the chosen tab's way. */
|
|
202
|
-
.es-tab:hover:not(.is-active)
|
|
228
|
+
.es-tab:hover:not(.is-active),
|
|
229
|
+
.es-seg:hover:not(.is-active) { color: var(--es-text); }
|
|
203
230
|
|
|
204
231
|
/* Keyboard focus, themed rather than left to the UA. Same ring as the switch,
|
|
205
232
|
so the panel answers a keyboard the one way. */
|
|
206
|
-
.es-tab:focus-visible
|
|
233
|
+
.es-tab:focus-visible,
|
|
234
|
+
.es-seg:focus-visible {
|
|
207
235
|
outline: none;
|
|
208
236
|
box-shadow: 0 0 0 3px var(--es-accent-ring);
|
|
209
237
|
}
|
|
@@ -226,7 +254,8 @@
|
|
|
226
254
|
So the tint goes, the top-only corners go, and the 2px accent rule goes with
|
|
227
255
|
them. The chip is told apart by ELEVATION now — a lit rule under a raised
|
|
228
256
|
chip is a second answer to a question that already has one. */
|
|
229
|
-
.es-tab.is-active
|
|
257
|
+
.es-tab.is-active,
|
|
258
|
+
.es-seg.is-active {
|
|
230
259
|
background: var(--es-raised);
|
|
231
260
|
color: var(--es-text);
|
|
232
261
|
box-shadow: var(--es-raised-shadow);
|
|
@@ -235,7 +264,8 @@
|
|
|
235
264
|
|
|
236
265
|
/* Focused AND chosen: keep the lift, or the chip drops flat the moment a
|
|
237
266
|
keyboard reaches it. */
|
|
238
|
-
.es-tab.is-active:focus-visible
|
|
267
|
+
.es-tab.is-active:focus-visible,
|
|
268
|
+
.es-seg.is-active:focus-visible {
|
|
239
269
|
box-shadow: var(--es-raised-shadow), 0 0 0 3px var(--es-accent-ring);
|
|
240
270
|
}
|
|
241
271
|
|
|
@@ -270,16 +300,18 @@
|
|
|
270
300
|
|
|
271
301
|
`flex: none` because a glyph that shrinks is a squeezed segment in a smaller
|
|
272
302
|
box. */
|
|
273
|
-
.es-tab-icon
|
|
303
|
+
.es-tab-icon,
|
|
304
|
+
.es-seg-icon {
|
|
274
305
|
display: inline-flex;
|
|
275
|
-
width:
|
|
306
|
+
width: var(--es-chip-icon);
|
|
276
307
|
height: 18px;
|
|
277
308
|
flex: none;
|
|
278
309
|
align-items: center;
|
|
279
310
|
justify-content: center;
|
|
280
311
|
}
|
|
281
312
|
|
|
282
|
-
.es-tab-icon svg
|
|
313
|
+
.es-tab-icon svg,
|
|
314
|
+
.es-seg-icon svg { width: var(--es-chip-icon); height: var(--es-chip-icon); display: block; }
|
|
283
315
|
|
|
284
316
|
/* ── where the word stops fitting ──────────────────────────────────────────── */
|
|
285
317
|
|
|
@@ -293,10 +325,21 @@
|
|
|
293
325
|
* Smallest container width with nothing drawn past the track edge:
|
|
294
326
|
*
|
|
295
327
|
* tabs words glyph + active word glyphs only
|
|
296
|
-
* 2 168px 126px
|
|
297
|
-
* 3 239px 161px
|
|
328
|
+
* 2 168px 126px 73px
|
|
329
|
+
* 3 239px 161px 108px
|
|
298
330
|
* 4 320px 196px 143px
|
|
299
331
|
*
|
|
332
|
+
* THE GLYPHS-ONLY COLUMN SAID 120px FOR BOTH 2 AND 3 TABS UNTIL CARD 66248,
|
|
333
|
+
* and it was wrong twice over. `tabs-segmented-fit.html` searched upward from
|
|
334
|
+
* 110px, so it could never have reported anything smaller than 110 whatever the
|
|
335
|
+
* truth was — and 120 is not even that, so a second hand had been on it. The
|
|
336
|
+
* truth is arithmetic and there is nothing to measure: a chip with no word is a
|
|
337
|
+
* glyph in its padding, so the strip is `n × 32px` plus `(n − 1)` gaps plus the
|
|
338
|
+
* track's own padding — 73, 108, 143. The 4-tab cell was right by luck of being
|
|
339
|
+
* above the floor. Re-measured with the search starting at 40px, all three
|
|
340
|
+
* agree with the arithmetic to the pixel; `(d)` now pins them to it, so the
|
|
341
|
+
* cell cannot drift from the sheet again.
|
|
342
|
+
*
|
|
300
343
|
* The drag band gives containers from 181px (a 220px panel, the minimum) to
|
|
301
344
|
* 281px (a 320px panel, the default — the panel minus 28px of body padding
|
|
302
345
|
* minus the 11px reserved scrollbar gutter). Read against it:
|
|
@@ -340,6 +383,135 @@
|
|
|
340
383
|
.es-tabs:has(.es-tab:nth-child(4)) .es-tab.has-icon.is-active .es-tab-word { display: none; }
|
|
341
384
|
}
|
|
342
385
|
|
|
386
|
+
/* ── the segmented control: the same chip, a different promise ─────────────── */
|
|
387
|
+
|
|
388
|
+
/* WHY A SECOND COMPONENT AND NOT A SECOND CALLER OF `SettingsTabs` (card 66248).
|
|
389
|
+
`SettingsTabs` is a TABLIST, and `role="tab"` is the promise that pressing a
|
|
390
|
+
chip switches a panel. The rows this control is for switch nothing: Weight,
|
|
391
|
+
Case and Alignment WRITE A VALUE and stay where they are. Pointing the tab
|
|
392
|
+
strip at them would make three controls that are correct today start
|
|
393
|
+
announcing "tab, 1 of 4, selected" over nothing. Everything a merchant SEES
|
|
394
|
+
is shared above — one chip, one track, one raised-chip mark — and only the
|
|
395
|
+
promise differs. It is the split this sheet already made between
|
|
396
|
+
`.es-group-head` and `button.es-group-head`.
|
|
397
|
+
|
|
398
|
+
── WHY THE @container TIERS ABOVE ARE NOT INHERITED ────────────────────────
|
|
399
|
+
|
|
400
|
+
Those three tiers are keyed on the tab COUNT, and that works for the strip
|
|
401
|
+
they were measured for because the shell KNOWS its four words: Content /
|
|
402
|
+
Media / Style / Layout. It cannot work here, and the reason is arithmetic,
|
|
403
|
+
not taste. MEASURED in Chromium, `tests/fixtures/segmented-fit.html`, the
|
|
404
|
+
width at which each row's words stop fitting:
|
|
405
|
+
|
|
406
|
+
row options words fit from
|
|
407
|
+
Case Normal / Uppercase / Lowercase 3 296px
|
|
408
|
+
Alignment Left / Centre / Right 3 221px
|
|
409
|
+
|
|
410
|
+
Two three-option rows, 75px apart. One threshold at three options is wrong
|
|
411
|
+
for one of them whichever number is picked — and the panel drags between
|
|
412
|
+
181px and 281px, so the wrong one is wrong ON SCREEN: at the default 281px
|
|
413
|
+
panel Alignment's words fit and Case's do not.
|
|
414
|
+
|
|
415
|
+
Whether the words fit is a question about the SUM OF THE LABELS, which is
|
|
416
|
+
what this sheet already says out loud above, and a count is only ever a
|
|
417
|
+
stand-in for it. The stand-in holds while one strip's words are known and
|
|
418
|
+
breaks the moment a second strip brings its own.
|
|
419
|
+
|
|
420
|
+
── SO THE THRESHOLD IS COMPUTED, PER STRIP ─────────────────────────────────
|
|
421
|
+
|
|
422
|
+
Everything in that sum except the words is the CHIP'S, and named on the
|
|
423
|
+
panel root above. Only the words belong to the consumer. So the strip
|
|
424
|
+
declares its own two measurements and this sheet adds the chrome:
|
|
425
|
+
|
|
426
|
+
words-from = the track + every chip with its glyph, its gap and its word
|
|
427
|
+
chosen-from = the track + every chip's glyph, plus one word — the widest,
|
|
428
|
+
because the merchant may be on any option and the strip may
|
|
429
|
+
not overflow on the one that costs most
|
|
430
|
+
|
|
431
|
+
WHY NOT `@container` HERE. A container query condition takes a literal
|
|
432
|
+
length; it cannot read a custom property, so it cannot ask a question whose
|
|
433
|
+
answer differs per strip. Container query UNITS can: `100cqi` is the panel's
|
|
434
|
+
own width, in a place `calc()` can use it. So the comparison is done in the
|
|
435
|
+
property instead of in the query — subtract the threshold from the width and
|
|
436
|
+
scale hard. Above the threshold that is a large positive length and the word
|
|
437
|
+
is unlimited; below it the result is negative, and `max-width` clamps a
|
|
438
|
+
negative to zero, which is the word gone. Same two tiers as the strip above,
|
|
439
|
+
measured continuously instead of at three fixed steps.
|
|
440
|
+
|
|
441
|
+
WHY NOT ESTIMATE THE WORDS FROM THEIR LETTERS, and skip the measuring. The
|
|
442
|
+
chip's font is `--es-font`, which resolves to SF on a Mac, Segoe UI on
|
|
443
|
+
Windows and something else again elsewhere — one shipped table of letter
|
|
444
|
+
widths would be wrong on two platforms out of three. Measured against these
|
|
445
|
+
labels the guess is out by up to 7% either way, which is 12px on a 170px sum:
|
|
446
|
+
enough to drop a word that fits, or keep one that does not.
|
|
447
|
+
|
|
448
|
+
WHY NOT MEASURE IT AT RUNTIME. That is a `ResizeObserver` and an effect, and
|
|
449
|
+
this module is a hook-free leaf a server component can import. The host could
|
|
450
|
+
do it, but then every consumer writes its own and they drift — which is the
|
|
451
|
+
thing this package exists to stop. */
|
|
452
|
+
|
|
453
|
+
/* NO MEASUREMENT, NO DROP. A strip that never declared where its words stop
|
|
454
|
+
fitting keeps every word at every width, exactly as a chip with no glyph
|
|
455
|
+
does: there is nothing to fall back TO and nowhere honest to fall back AT.
|
|
456
|
+
`is-measured` is the component's own answer to whether it was given a `fit`,
|
|
457
|
+
so the fallback below is reached by a strip that has one and never by a strip
|
|
458
|
+
that does not. */
|
|
459
|
+
.es-segs.is-measured {
|
|
460
|
+
/* one chip's chrome, with and without room for a word beside the glyph */
|
|
461
|
+
--es-seg-chip: calc(2 * var(--es-chip-pad-x) + var(--es-chip-icon));
|
|
462
|
+
/* the track's own padding, and the gaps between the chips on it */
|
|
463
|
+
--es-seg-track: calc(2 * var(--es-track-pad) + (var(--es-seg-count) - 1) * var(--es-track-gap));
|
|
464
|
+
|
|
465
|
+
--es-seg-words-from: calc(
|
|
466
|
+
var(--es-seg-track) +
|
|
467
|
+
var(--es-seg-count) * (var(--es-seg-chip) + var(--es-chip-icon-gap)) +
|
|
468
|
+
var(--es-seg-labels)
|
|
469
|
+
);
|
|
470
|
+
--es-seg-chosen-from: calc(
|
|
471
|
+
var(--es-seg-track) +
|
|
472
|
+
var(--es-seg-count) * var(--es-seg-chip) +
|
|
473
|
+
var(--es-chip-icon-gap) +
|
|
474
|
+
var(--es-seg-widest)
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/* THE ROOM LEFT OVER, once this strip's own words are paid for. Positive and
|
|
479
|
+
large while they fit, negative below — and the two properties that read it
|
|
480
|
+
both clamp a negative to zero, which is what makes it a switch rather than a
|
|
481
|
+
width. The 10000 is what makes the change a step and not a fade: without it
|
|
482
|
+
the last pixel before the threshold would draw a one-pixel word. */
|
|
483
|
+
.es-segs {
|
|
484
|
+
--es-seg-room-all: calc((100cqi - var(--es-seg-words-from, 0px)) * 10000);
|
|
485
|
+
--es-seg-room-chosen: calc((100cqi - var(--es-seg-chosen-from, 0px)) * 10000);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/* The word is CLIPPED to nothing rather than `display: none`, because the
|
|
489
|
+
switch has to be a length for `max-width` to clamp it. Nothing is lost by
|
|
490
|
+
it: the label is on the button as `title` and `aria-label` in every state, so
|
|
491
|
+
the name a screen reader reads never depended on the span being drawn. */
|
|
492
|
+
.es-seg-word {
|
|
493
|
+
overflow: hidden;
|
|
494
|
+
max-width: var(--es-seg-room-all);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/* THE CHOSEN SEGMENT KEEPS ITS WORD LONGER — the second tier, and the same one
|
|
498
|
+
the tab strip drops to. A track of bare glyphs still reads, because the track
|
|
499
|
+
and the chips are what say "these are buttons"; a track that also still says
|
|
500
|
+
which value is set reads better, and it costs one word. */
|
|
501
|
+
.es-seg.is-active .es-seg-word { max-width: var(--es-seg-room-chosen); }
|
|
502
|
+
|
|
503
|
+
/* THE GAP GOES WITH THE WORD IT SEPARATES. The chip's `gap` is a flex gap, and
|
|
504
|
+
a flex gap does not care that the word is now zero wide — it would leave 6px
|
|
505
|
+
of nothing between the glyph and the panel edge and put every glyphs-only
|
|
506
|
+
strip 6px per chip over its real width. So the segment spends the gap as the
|
|
507
|
+
word's OWN margin and switches it with the same room. The tab strip is
|
|
508
|
+
untouched and keeps the flex gap, which is right there: its word is
|
|
509
|
+
`display: none`, so the gap goes with the element, and a tab also has a badge
|
|
510
|
+
on the other side of that word for the gap to separate. */
|
|
511
|
+
.es-seg { gap: 0; }
|
|
512
|
+
.es-seg.has-icon .es-seg-word { margin-left: clamp(0px, var(--es-seg-room-all), var(--es-chip-icon-gap)); }
|
|
513
|
+
.es-seg.has-icon.is-active .es-seg-word { margin-left: clamp(0px, var(--es-seg-room-chosen), var(--es-chip-icon-gap)); }
|
|
514
|
+
|
|
343
515
|
/* ── a group: the soft pill row that folds ─────────────────────────────────── */
|
|
344
516
|
|
|
345
517
|
/* WAS 6px, AND THE 6 NEVER APPLIED (card 66203). `.es-group` is a plain div, so
|
|
@@ -698,6 +870,7 @@ button.es-group-head:hover { background: var(--es-chip-bg); }
|
|
|
698
870
|
|
|
699
871
|
@media (prefers-reduced-motion: reduce) {
|
|
700
872
|
.es-tab,
|
|
873
|
+
.es-seg,
|
|
701
874
|
/* `button.` and not `.es-group-head`: the transition it cancels is declared
|
|
702
875
|
on `button.es-group-head`, and a plainer selector would lose to it. */
|
|
703
876
|
button.es-group-head,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "editor-shell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "Shared editor-chrome primitives for the EFFICIENT editors: EditorRail (a Next-16-safe left icon-rail leaf), the shell token layer, and GoldTextInput — type in place and watch the markup formatting appear as you type.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Lewis Liu",
|