@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.
Files changed (2) hide show
  1. package/package.json +2 -1
  2. package/src/Segmented.astro +216 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justai/cuts",
3
- "version": "0.68.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>