@justai/cuts 0.67.0 → 0.69.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 +202 -0
- package/src/Toolbar.astro +33 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@justai/cuts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.69.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,202 @@
|
|
|
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
|
+
interface Props {
|
|
63
|
+
id: string;
|
|
64
|
+
options: Option[];
|
|
65
|
+
/** Which one is on. Defaults to the first, because a segmented control is never in no state. */
|
|
66
|
+
value?: string;
|
|
67
|
+
/** An accessible name for the group. The question the options answer. */
|
|
68
|
+
label?: string;
|
|
69
|
+
/** `full` stretches to the container (a view's own switch); `inline` hugs its content (a toolbar). */
|
|
70
|
+
width?: "full" | "inline";
|
|
71
|
+
}
|
|
72
|
+
const { id, options, value, label = "", width = "full" } = Astro.props as Props;
|
|
73
|
+
const on = value && options.some((o) => o.value === value) ? value : options[0]?.value;
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
<div class="jseg" id={id} role="radiogroup" aria-label={label} data-width={width} data-value={on}>
|
|
77
|
+
{options.map((o) => (
|
|
78
|
+
<button
|
|
79
|
+
class="jseg-item"
|
|
80
|
+
type="button"
|
|
81
|
+
role="radio"
|
|
82
|
+
data-value={o.value}
|
|
83
|
+
aria-checked={o.value === on ? "true" : "false"}
|
|
84
|
+
aria-disabled={o.disabled ? "true" : undefined}
|
|
85
|
+
tabindex={o.value === on ? 0 : -1}
|
|
86
|
+
>
|
|
87
|
+
<slot name={`icon-${o.value}`} />
|
|
88
|
+
<span class="jseg-label">{o.label}</span>
|
|
89
|
+
</button>
|
|
90
|
+
))}
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<style>
|
|
94
|
+
/* queries: segmented control styles · the chosen segment · ios segmented look
|
|
95
|
+
ONE TRACK, THE CHOSEN ONE RAISED. The track is recessed and the selection sits on top of it —
|
|
96
|
+
which is what makes a segmented control read as "these are the options" rather than as a row of
|
|
97
|
+
buttons where one happens to be coloured. */
|
|
98
|
+
.jseg {
|
|
99
|
+
/* ITS OWN BOX MODEL, not the consumer's. `width: 100%` plus a padding and a border comes out
|
|
100
|
+
SIX pixels wider than its parent unless something has already said `border-box` — and on a
|
|
101
|
+
bare page nothing has. Inheriting a reset is homework (law 1), and this is the shape that
|
|
102
|
+
homework takes when nobody notices: a control that fits everywhere it was tried. */
|
|
103
|
+
box-sizing: border-box;
|
|
104
|
+
display: inline-flex;
|
|
105
|
+
align-items: stretch;
|
|
106
|
+
gap: 2px;
|
|
107
|
+
padding: 2px;
|
|
108
|
+
border-radius: var(--radius-md);
|
|
109
|
+
background: var(--surface-2);
|
|
110
|
+
border: 1px solid var(--border);
|
|
111
|
+
/* A control is one tab stop. The roving tabindex below is what makes that true for the keyboard;
|
|
112
|
+
this is what makes it true for the eye. */
|
|
113
|
+
isolation: isolate;
|
|
114
|
+
}
|
|
115
|
+
.jseg[data-width="full"] { display: flex; width: 100%; }
|
|
116
|
+
.jseg[data-width="full"] .jseg-item { flex: 1; }
|
|
117
|
+
|
|
118
|
+
.jseg-item {
|
|
119
|
+
box-sizing: border-box;
|
|
120
|
+
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
|
121
|
+
min-width: 0; padding: 7px 12px;
|
|
122
|
+
border: 0; border-radius: calc(var(--radius-md) - 3px);
|
|
123
|
+
background: transparent; color: var(--text-dim);
|
|
124
|
+
font: inherit; font-size: var(--fs-sm); font-weight: 500; line-height: 1.2;
|
|
125
|
+
cursor: pointer;
|
|
126
|
+
transition: background 0.18s var(--ease-out), color 0.18s var(--ease-out);
|
|
127
|
+
}
|
|
128
|
+
.jseg-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
129
|
+
.jseg-item :global(svg) { width: 16px; height: 16px; flex: none; }
|
|
130
|
+
|
|
131
|
+
/* THE CHOSEN ONE. `--surface` against a `--surface-2` track is the raise; the accent names it. */
|
|
132
|
+
.jseg-item[aria-checked="true"] {
|
|
133
|
+
background: var(--surface);
|
|
134
|
+
color: var(--accent);
|
|
135
|
+
box-shadow: 0 1px 2px rgb(0 0 0 / 0.06);
|
|
136
|
+
}
|
|
137
|
+
.jseg-item[aria-disabled="true"] { opacity: 0.45; cursor: default; }
|
|
138
|
+
.jseg-item:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
139
|
+
|
|
140
|
+
/* Motion is never forced on somebody who asked it off. */
|
|
141
|
+
@media (prefers-reduced-motion: reduce) {
|
|
142
|
+
.jseg-item { transition: none; }
|
|
143
|
+
}
|
|
144
|
+
</style>
|
|
145
|
+
|
|
146
|
+
<script>
|
|
147
|
+
// queries: segmented control keyboard · arrow keys · roving tabindex · aria-checked · change event
|
|
148
|
+
//
|
|
149
|
+
// ROVING TABINDEX IS THE WHOLE OF THE KEYBOARD. A radiogroup is ONE tab stop: Tab reaches the
|
|
150
|
+
// chosen option and the arrows move within the group. Leaving every option at `tabIndex 0` is the
|
|
151
|
+
// common mistake and it is invisible to anyone using a mouse — a person on a keyboard suddenly has
|
|
152
|
+
// to press Tab three times to get past a control they were not using.
|
|
153
|
+
//
|
|
154
|
+
// Lifted from qr, which had it right and had it alone.
|
|
155
|
+
const wire = (root: HTMLElement) => {
|
|
156
|
+
const items = () => Array.from(root.querySelectorAll<HTMLElement>('[role="radio"]'));
|
|
157
|
+
|
|
158
|
+
const set = (el: HTMLElement, focus?: boolean, quiet?: boolean) => {
|
|
159
|
+
if (el.getAttribute("aria-disabled") === "true") return;
|
|
160
|
+
const next = el.dataset.value!;
|
|
161
|
+
if (root.dataset.value === next) { if (focus) el.focus(); return; }
|
|
162
|
+
for (const r of items()) {
|
|
163
|
+
const chosen = r === el;
|
|
164
|
+
r.setAttribute("aria-checked", String(chosen));
|
|
165
|
+
r.tabIndex = chosen ? 0 : -1;
|
|
166
|
+
}
|
|
167
|
+
root.dataset.value = next;
|
|
168
|
+
if (focus) el.focus();
|
|
169
|
+
// Setting `.value` from code does NOT re-announce — a caller that just said what it wants does
|
|
170
|
+
// not need to be told, and a listener that reacts by setting the value would loop.
|
|
171
|
+
if (!quiet) root.dispatchEvent(new CustomEvent("change", { detail: { value: next }, bubbles: true }));
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
for (const el of items()) el.addEventListener("click", () => set(el, true));
|
|
175
|
+
|
|
176
|
+
root.addEventListener("keydown", (e) => {
|
|
177
|
+
const key = (e as KeyboardEvent).key;
|
|
178
|
+
const all = items().filter((r) => r.getAttribute("aria-disabled") !== "true");
|
|
179
|
+
const at = all.findIndex((r) => r.getAttribute("aria-checked") === "true");
|
|
180
|
+
let to = -1;
|
|
181
|
+
if (key === "ArrowRight" || key === "ArrowDown") to = (at + 1) % all.length;
|
|
182
|
+
else if (key === "ArrowLeft" || key === "ArrowUp") to = (at - 1 + all.length) % all.length;
|
|
183
|
+
else if (key === "Home") to = 0;
|
|
184
|
+
else if (key === "End") to = all.length - 1;
|
|
185
|
+
if (to < 0 || !all[to]) return;
|
|
186
|
+
e.preventDefault();
|
|
187
|
+
set(all[to], true);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// The one thing a caller needs beyond listening: saying what it wants.
|
|
191
|
+
Object.defineProperty(root, "value", {
|
|
192
|
+
get: () => root.dataset.value,
|
|
193
|
+
set: (v: string) => {
|
|
194
|
+
const el = items().find((r) => r.dataset.value === v);
|
|
195
|
+
if (el) set(el, false, true);
|
|
196
|
+
},
|
|
197
|
+
configurable: true,
|
|
198
|
+
});
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
for (const root of document.querySelectorAll<HTMLElement>(".jseg")) wire(root);
|
|
202
|
+
</script>
|
package/src/Toolbar.astro
CHANGED
|
@@ -50,13 +50,33 @@ interface Props {
|
|
|
50
50
|
// destination, and justai.pro is above it (sending the parent "home" to a child platform is
|
|
51
51
|
// the wrong direction). Every other persona keeps it.
|
|
52
52
|
home?: boolean;
|
|
53
|
+
// What the title strip says when the Kernel or the Posts cut is the one at the toolbar line.
|
|
54
|
+
//
|
|
55
|
+
// OMIT THEM AND NOTHING MOVES: the strip keeps naming the persona and its handle, which is right
|
|
56
|
+
// almost everywhere. On a leaf the kernel IS the persona — the strip reading "QR Code" while you
|
|
57
|
+
// use qr replaces the profile header that scrolled away, and "App" would be strictly less.
|
|
58
|
+
//
|
|
59
|
+
// app.zone is the exception the pair exists for, and the reason is structural rather than a matter
|
|
60
|
+
// of taste: it is the one persona whose kernel is NOT itself. Its kernel is a catalogue of the
|
|
61
|
+
// others, and its profile header — a few pixels above — already says app.zone. So the strip spends
|
|
62
|
+
// its only line repeating what the visitor can see. There it says "Apps" and "Posts" instead.
|
|
63
|
+
//
|
|
64
|
+
// BOTH OR NEITHER, deliberately. Half an override would need this file to reproduce the other
|
|
65
|
+
// half's default, which means copying core's `.pname` / `.handle` selectors into a second place
|
|
66
|
+
// that nothing keeps in step. The strip is one line and its vocabulary should be one kind of
|
|
67
|
+
// thing — names or sections, not one of each — so the API says so out loud.
|
|
68
|
+
kernelTitle?: string;
|
|
69
|
+
postsTitle?: string;
|
|
53
70
|
}
|
|
54
71
|
import BackToZone from "./BackToZone.astro";
|
|
55
|
-
const { sectionsLabel, profileLabel, kernelLabel, postsLabel, home = true, files = true, filesLabel = "Files" } = Astro.props;
|
|
72
|
+
const { sectionsLabel, profileLabel, kernelLabel, postsLabel, home = true, files = true, filesLabel = "Files", kernelTitle, postsTitle } = Astro.props;
|
|
56
73
|
import AuthMount from "./AuthMount.astro";
|
|
57
74
|
---
|
|
58
75
|
|
|
59
|
-
|
|
76
|
+
<!-- The two titles reach the script as data attributes rather than through `define:vars`, which would
|
|
77
|
+
make this script inline and it has an import. Astro drops an attribute whose value is undefined,
|
|
78
|
+
so a persona that passes neither leaves no trace in the markup at all. -->
|
|
79
|
+
<header class="toolbar" data-kernel-title={kernelTitle} data-posts-title={postsTitle}>
|
|
60
80
|
<div class="tb-row">
|
|
61
81
|
<div class="tb-left">
|
|
62
82
|
{home && <BackToZone />}
|
|
@@ -216,5 +236,15 @@ import AuthMount from "./AuthMount.astro";
|
|
|
216
236
|
// The defaults ARE this cut's contract — the three cut ids, `nav<Cut>`, `#tbTitle`/`#tbTitleText`,
|
|
217
237
|
// `.toolbar`, `#below` — so a persona using the shell passes nothing at all.
|
|
218
238
|
import { personaScroll } from "@justai/core";
|
|
219
|
-
|
|
239
|
+
// Both or neither — see the props above. When both are set the strip names the SECTIONS; core's
|
|
240
|
+
// default, which names the persona and its handle, is left entirely alone otherwise. Nothing here
|
|
241
|
+
// reproduces that default, which is the point: `.pname` and `.handle` stay core's business.
|
|
242
|
+
const bar = document.querySelector(".toolbar") as HTMLElement | null;
|
|
243
|
+
const kt = bar?.dataset.kernelTitle;
|
|
244
|
+
const pt = bar?.dataset.postsTitle;
|
|
245
|
+
personaScroll(
|
|
246
|
+
kt && pt
|
|
247
|
+
? { titleFor: (id: string) => (id === "kernel" ? kt : id === "posts" ? pt : "") }
|
|
248
|
+
: undefined,
|
|
249
|
+
);
|
|
220
250
|
</script>
|