@justai/cuts 0.68.0 → 0.70.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/package.json +2 -1
- package/src/Segmented.astro +216 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@justai/cuts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.70.0",
|
|
4
4
|
"description": "A persona's named parts \u2014 the page shell that holds them, and the cuts themselves.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"./Profile.astro": "./src/Profile.astro",
|
|
22
22
|
"./ConfirmSheet.astro": "./src/ConfirmSheet.astro",
|
|
23
23
|
"./Sheet.astro": "./src/Sheet.astro",
|
|
24
|
+
"./Segmented.astro": "./src/Segmented.astro",
|
|
24
25
|
"./ground.css": "./src/ground.css",
|
|
25
26
|
"./FilePicker.astro": "./src/FilePicker.astro",
|
|
26
27
|
"./ActionList.astro": "./src/ActionList.astro",
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
---
|
|
2
|
+
// THE GROUND rides with the control, same as every cut — law 1 is a file it imports, not a promise.
|
|
3
|
+
import "./ground.css";
|
|
4
|
+
/**
|
|
5
|
+
* <Segmented> — one question, two or three answers, all of them visible.
|
|
6
|
+
*
|
|
7
|
+
* queries: segmented control · tab group · button group · toggle between two things · iOS picker ·
|
|
8
|
+
* two buttons side by side · switch between device and cloud · PHOTO or VIDEO · radiogroup
|
|
9
|
+
*
|
|
10
|
+
* ── WHAT IT IS, AND WHY IT IS THIS AND NOT TABS ──────────────────────────────────────────────
|
|
11
|
+
*
|
|
12
|
+
* iOS has one answer for "which of these few?" and it is the SEGMENTED CONTROL: a single track with
|
|
13
|
+
* the options inside it and a pill sliding to the chosen one. It is not a tab bar — a tab bar
|
|
14
|
+
* changes the SECTION you are in and lives at an edge of the screen. A segmented control changes
|
|
15
|
+
* what the view in front of you is SHOWING, and sits inside that view.
|
|
16
|
+
*
|
|
17
|
+
* The distinction decides where it may be used. `On this device` / `In the cloud` is the same room
|
|
18
|
+
* showing a different shelf, so it is this. `PHOTO` / `VIDEO` is the same camera in a different
|
|
19
|
+
* mode, so it is this too. Moving between a persona's profile and its kernel is not — that is the
|
|
20
|
+
* toolbar's job.
|
|
21
|
+
*
|
|
22
|
+
* ── WHY IT IS A CUT, WITH THE COUNT ──────────────────────────────────────────────────────────
|
|
23
|
+
*
|
|
24
|
+
* Four of these already existed, hand-rolled, in three repos: `files` has two (the storage tabs and
|
|
25
|
+
* the camera's mode), `time` has one in its toolbar, and `qr` has FOUR MORE behind one module whose
|
|
26
|
+
* own comment says it — *"four groups on this page are the same control wearing different clothes:
|
|
27
|
+
* the colour swatches, the module shape, the raster size, and the centre logo."*
|
|
28
|
+
*
|
|
29
|
+
* And they had split the work in half, each keeping the piece the other lost:
|
|
30
|
+
*
|
|
31
|
+
* · **qr had the BEHAVIOUR right** — `role=radiogroup`, roving tabindex, arrow keys. That is an
|
|
32
|
+
* accessibility pattern, and the part that is easy to get subtly wrong: leaving every option at
|
|
33
|
+
* `tabIndex 0` is invisible to a mouse and makes a keyboard press Tab six times to pass a row.
|
|
34
|
+
* · **time had the LOOK right** — a `surface-2` track, a hairline gap, the chosen segment raised
|
|
35
|
+
* on its own rounded fill.
|
|
36
|
+
* · **files had neither**, twice.
|
|
37
|
+
*
|
|
38
|
+
* That is what a copied control does: it does not diverge in one direction, it diverges in as many
|
|
39
|
+
* directions as there are copies, and each is right about something the others forgot.
|
|
40
|
+
*
|
|
41
|
+
* ── USE ──────────────────────────────────────────────────────────────────────────────────────
|
|
42
|
+
*
|
|
43
|
+
* <Segmented id="where" value="device" options={[
|
|
44
|
+
* { value: "device", label: t.onDevice },
|
|
45
|
+
* { value: "cloud", label: t.inCloud, disabled: true },
|
|
46
|
+
* ]} />
|
|
47
|
+
*
|
|
48
|
+
* const seg = document.getElementById("where");
|
|
49
|
+
* seg.addEventListener("change", (e) => render(e.detail.value));
|
|
50
|
+
* seg.value = "cloud"; // set it from code; the event does not re-fire
|
|
51
|
+
*
|
|
52
|
+
* NO HOMEWORK (law 1): no script to copy, no class names to learn, no keyboard handling to
|
|
53
|
+
* remember. A consumer names the options and listens.
|
|
54
|
+
*/
|
|
55
|
+
export interface Option {
|
|
56
|
+
value: string;
|
|
57
|
+
label: string;
|
|
58
|
+
/** Shown, never hidden — an option a person can see and not reach explains itself; one that
|
|
59
|
+
* vanishes reads as a feature that was removed. */
|
|
60
|
+
disabled?: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* A translation key, for a consumer that swaps its words after render.
|
|
63
|
+
*
|
|
64
|
+
* queries: translate a segmented control · data-i · the labels stay English · one bundle many
|
|
65
|
+
* languages
|
|
66
|
+
*
|
|
67
|
+
* THE ECOSYSTEM'S OWN CONVENTION, carried by the component rather than left to the caller. A
|
|
68
|
+
* mounted child persona (`/files`, `/me`) is ONE static bundle on every host, so it cannot have
|
|
69
|
+
* per-locale routes and translates in the browser by sweeping `[data-i]`. A control that renders
|
|
70
|
+
* its label as bare text is a control whose words stay English in thirteen languages — and the
|
|
71
|
+
* consumer's only fix would be to reach inside and add the attribute itself, which is the
|
|
72
|
+
* homework law 1 exists to refuse.
|
|
73
|
+
*/
|
|
74
|
+
i18n?: string;
|
|
75
|
+
}
|
|
76
|
+
interface Props {
|
|
77
|
+
id: string;
|
|
78
|
+
options: Option[];
|
|
79
|
+
/** Which one is on. Defaults to the first, because a segmented control is never in no state. */
|
|
80
|
+
value?: string;
|
|
81
|
+
/** An accessible name for the group. The question the options answer. */
|
|
82
|
+
label?: string;
|
|
83
|
+
/** `full` stretches to the container (a view's own switch); `inline` hugs its content (a toolbar). */
|
|
84
|
+
width?: "full" | "inline";
|
|
85
|
+
}
|
|
86
|
+
const { id, options, value, label = "", width = "full" } = Astro.props as Props;
|
|
87
|
+
const on = value && options.some((o) => o.value === value) ? value : options[0]?.value;
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
<div class="jseg" id={id} role="radiogroup" aria-label={label} data-width={width} data-value={on}>
|
|
91
|
+
{options.map((o) => (
|
|
92
|
+
<button
|
|
93
|
+
class="jseg-item"
|
|
94
|
+
type="button"
|
|
95
|
+
role="radio"
|
|
96
|
+
data-value={o.value}
|
|
97
|
+
aria-checked={o.value === on ? "true" : "false"}
|
|
98
|
+
aria-disabled={o.disabled ? "true" : undefined}
|
|
99
|
+
tabindex={o.value === on ? 0 : -1}
|
|
100
|
+
>
|
|
101
|
+
<slot name={`icon-${o.value}`} />
|
|
102
|
+
<span class="jseg-label" data-i={o.i18n}>{o.label}</span>
|
|
103
|
+
</button>
|
|
104
|
+
))}
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<style>
|
|
108
|
+
/* queries: segmented control styles · the chosen segment · ios segmented look
|
|
109
|
+
ONE TRACK, THE CHOSEN ONE RAISED. The track is recessed and the selection sits on top of it —
|
|
110
|
+
which is what makes a segmented control read as "these are the options" rather than as a row of
|
|
111
|
+
buttons where one happens to be coloured. */
|
|
112
|
+
.jseg {
|
|
113
|
+
/* ITS OWN BOX MODEL, not the consumer's. `width: 100%` plus a padding and a border comes out
|
|
114
|
+
SIX pixels wider than its parent unless something has already said `border-box` — and on a
|
|
115
|
+
bare page nothing has. Inheriting a reset is homework (law 1), and this is the shape that
|
|
116
|
+
homework takes when nobody notices: a control that fits everywhere it was tried. */
|
|
117
|
+
box-sizing: border-box;
|
|
118
|
+
display: inline-flex;
|
|
119
|
+
align-items: stretch;
|
|
120
|
+
gap: 2px;
|
|
121
|
+
padding: 2px;
|
|
122
|
+
border-radius: var(--radius-md);
|
|
123
|
+
background: var(--surface-2);
|
|
124
|
+
border: 1px solid var(--border);
|
|
125
|
+
/* A control is one tab stop. The roving tabindex below is what makes that true for the keyboard;
|
|
126
|
+
this is what makes it true for the eye. */
|
|
127
|
+
isolation: isolate;
|
|
128
|
+
}
|
|
129
|
+
.jseg[data-width="full"] { display: flex; width: 100%; }
|
|
130
|
+
.jseg[data-width="full"] .jseg-item { flex: 1; }
|
|
131
|
+
|
|
132
|
+
.jseg-item {
|
|
133
|
+
box-sizing: border-box;
|
|
134
|
+
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
|
135
|
+
min-width: 0; padding: 7px 12px;
|
|
136
|
+
border: 0; border-radius: calc(var(--radius-md) - 3px);
|
|
137
|
+
background: transparent; color: var(--text-dim);
|
|
138
|
+
font: inherit; font-size: var(--fs-sm); font-weight: 500; line-height: 1.2;
|
|
139
|
+
cursor: pointer;
|
|
140
|
+
transition: background 0.18s var(--ease-out), color 0.18s var(--ease-out);
|
|
141
|
+
}
|
|
142
|
+
.jseg-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
143
|
+
.jseg-item :global(svg) { width: 16px; height: 16px; flex: none; }
|
|
144
|
+
|
|
145
|
+
/* THE CHOSEN ONE. `--surface` against a `--surface-2` track is the raise; the accent names it. */
|
|
146
|
+
.jseg-item[aria-checked="true"] {
|
|
147
|
+
background: var(--surface);
|
|
148
|
+
color: var(--accent);
|
|
149
|
+
box-shadow: 0 1px 2px rgb(0 0 0 / 0.06);
|
|
150
|
+
}
|
|
151
|
+
.jseg-item[aria-disabled="true"] { opacity: 0.45; cursor: default; }
|
|
152
|
+
.jseg-item:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
153
|
+
|
|
154
|
+
/* Motion is never forced on somebody who asked it off. */
|
|
155
|
+
@media (prefers-reduced-motion: reduce) {
|
|
156
|
+
.jseg-item { transition: none; }
|
|
157
|
+
}
|
|
158
|
+
</style>
|
|
159
|
+
|
|
160
|
+
<script>
|
|
161
|
+
// queries: segmented control keyboard · arrow keys · roving tabindex · aria-checked · change event
|
|
162
|
+
//
|
|
163
|
+
// ROVING TABINDEX IS THE WHOLE OF THE KEYBOARD. A radiogroup is ONE tab stop: Tab reaches the
|
|
164
|
+
// chosen option and the arrows move within the group. Leaving every option at `tabIndex 0` is the
|
|
165
|
+
// common mistake and it is invisible to anyone using a mouse — a person on a keyboard suddenly has
|
|
166
|
+
// to press Tab three times to get past a control they were not using.
|
|
167
|
+
//
|
|
168
|
+
// Lifted from qr, which had it right and had it alone.
|
|
169
|
+
const wire = (root: HTMLElement) => {
|
|
170
|
+
const items = () => Array.from(root.querySelectorAll<HTMLElement>('[role="radio"]'));
|
|
171
|
+
|
|
172
|
+
const set = (el: HTMLElement, focus?: boolean, quiet?: boolean) => {
|
|
173
|
+
if (el.getAttribute("aria-disabled") === "true") return;
|
|
174
|
+
const next = el.dataset.value!;
|
|
175
|
+
if (root.dataset.value === next) { if (focus) el.focus(); return; }
|
|
176
|
+
for (const r of items()) {
|
|
177
|
+
const chosen = r === el;
|
|
178
|
+
r.setAttribute("aria-checked", String(chosen));
|
|
179
|
+
r.tabIndex = chosen ? 0 : -1;
|
|
180
|
+
}
|
|
181
|
+
root.dataset.value = next;
|
|
182
|
+
if (focus) el.focus();
|
|
183
|
+
// Setting `.value` from code does NOT re-announce — a caller that just said what it wants does
|
|
184
|
+
// not need to be told, and a listener that reacts by setting the value would loop.
|
|
185
|
+
if (!quiet) root.dispatchEvent(new CustomEvent("change", { detail: { value: next }, bubbles: true }));
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
for (const el of items()) el.addEventListener("click", () => set(el, true));
|
|
189
|
+
|
|
190
|
+
root.addEventListener("keydown", (e) => {
|
|
191
|
+
const key = (e as KeyboardEvent).key;
|
|
192
|
+
const all = items().filter((r) => r.getAttribute("aria-disabled") !== "true");
|
|
193
|
+
const at = all.findIndex((r) => r.getAttribute("aria-checked") === "true");
|
|
194
|
+
let to = -1;
|
|
195
|
+
if (key === "ArrowRight" || key === "ArrowDown") to = (at + 1) % all.length;
|
|
196
|
+
else if (key === "ArrowLeft" || key === "ArrowUp") to = (at - 1 + all.length) % all.length;
|
|
197
|
+
else if (key === "Home") to = 0;
|
|
198
|
+
else if (key === "End") to = all.length - 1;
|
|
199
|
+
if (to < 0 || !all[to]) return;
|
|
200
|
+
e.preventDefault();
|
|
201
|
+
set(all[to], true);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// The one thing a caller needs beyond listening: saying what it wants.
|
|
205
|
+
Object.defineProperty(root, "value", {
|
|
206
|
+
get: () => root.dataset.value,
|
|
207
|
+
set: (v: string) => {
|
|
208
|
+
const el = items().find((r) => r.dataset.value === v);
|
|
209
|
+
if (el) set(el, false, true);
|
|
210
|
+
},
|
|
211
|
+
configurable: true,
|
|
212
|
+
});
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
for (const root of document.querySelectorAll<HTMLElement>(".jseg")) wire(root);
|
|
216
|
+
</script>
|