Skip to content

Commit a926a3c

Browse files
hyperpolymathclaude
andcommitted
feat(react): add Button.affine + Index.affine (sources for prior commit)
Follow-up to 114f7bf which deleted Button.tsx + index.ts but missed staging the new .affine files due to a chained-shell-command failure mid-pipeline. The .affine bodies and decomposition rationale are unchanged from what 114f7bf's commit message described. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 114f7bf commit a926a3c

2 files changed

Lines changed: 233 additions & 0 deletions

File tree

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
//
3+
// Button — accessible button, WCAG 2.1 AA.
4+
//
5+
// Re-decomposed from React.forwardRef(Button.tsx) per the AffineScript
6+
// migration playbook (Cardinal Rule: re-decompose, don't transliterate).
7+
//
8+
// Source-pattern decomposition applied here:
9+
// * `class` / `forwardRef` → standalone `make_button` fn returning own Element
10+
// * `useEffect` for class wiring → straight-line construction; no lifecycle needed
11+
// * `'primary' | 'secondary' | …` → sum types (ButtonVariant, ButtonSize)
12+
// * `Props extends HTMLButtonAttrs` → curated record; the HTML-attr "spread" escape
13+
// hatch is dropped — callers compose by mutating
14+
// the returned `own Element`
15+
// * `iconBefore?: ReactNode` → `Option[own Element]`; caller pre-builds the icon
16+
// * Exception path on missing args → defaulted via `default_button_props`
17+
//
18+
// Effect annotation: every DOM mutation is `IO` here. Once @affinescript/dom
19+
// publishes a typed `DOM` effect, swap the `/ IO` rows below to `/ DOM`.
20+
21+
// ── DOM FFI (inline; replace with `import @affinescript/dom` once that package
22+
// ── has a stable surface and a real cross-package import story in this repo)
23+
24+
extern type Element;
25+
extern type Event;
26+
27+
extern fn createElement(tag: ref String) -> own Element / IO;
28+
extern fn createTextNode(text: ref String) -> own Element / IO;
29+
extern fn setAttribute(el: mut Element, name: ref String, value: ref String) -> () / IO;
30+
extern fn appendChild(parent: mut Element, child: own Element) -> () / IO;
31+
extern fn addEventListener(
32+
el: mut Element,
33+
event: ref String,
34+
handler: fn(Event) -> () / IO
35+
) -> () / IO;
36+
37+
// ── Domain types ──────────────────────────────────────────────────────────────
38+
39+
type ButtonVariant = Primary | Secondary | Ghost | Danger
40+
type ButtonSize = Sm | Md | Lg
41+
42+
// `text` is mandatory (the visible label). Icons and click handler are optional
43+
// — `Option[…]` makes that visible at the call site, replacing the React
44+
// "undefined-by-omission" model.
45+
type ButtonProps = own {
46+
variant: ButtonVariant,
47+
size: ButtonSize,
48+
loading: Bool,
49+
disabled: Bool,
50+
class_extra: String, // appended to the computed class list
51+
button_type: String, // "button" | "submit" | "reset"
52+
aria_label: Option[String],
53+
on_click: Option[fn(Event) -> () / IO],
54+
text: own Element, // pre-built label (text node or richer subtree)
55+
icon_before: Option[own Element],
56+
icon_after: Option[own Element],
57+
}
58+
59+
fn default_button_props(text: own Element) -> own ButtonProps {
60+
ButtonProps {
61+
variant: Primary,
62+
size: Md,
63+
loading: false,
64+
disabled: false,
65+
class_extra: "",
66+
button_type: "button",
67+
aria_label: None,
68+
on_click: None,
69+
text: text,
70+
icon_before: None,
71+
icon_after: None,
72+
}
73+
}
74+
75+
// ── Class-name helpers (pure) ─────────────────────────────────────────────────
76+
77+
fn variant_class(v: ref ButtonVariant) -> String {
78+
match v {
79+
Primary => "a11y-button--primary"
80+
Secondary => "a11y-button--secondary"
81+
Ghost => "a11y-button--ghost"
82+
Danger => "a11y-button--danger"
83+
}
84+
}
85+
86+
fn size_class(s: ref ButtonSize) -> String {
87+
match s {
88+
Sm => "a11y-button--sm"
89+
Md => "a11y-button--md"
90+
Lg => "a11y-button--lg"
91+
}
92+
}
93+
94+
fn loading_class(loading: Bool) -> String {
95+
if loading { "a11y-button--loading" } else { "" }
96+
}
97+
98+
// Joins non-empty parts with single spaces.
99+
// (Stdlib presumably has String.join; this is a sketch using concat operators.)
100+
fn join_classes(
101+
base: ref String,
102+
v: ref String,
103+
s: ref String,
104+
l: ref String,
105+
extra: ref String
106+
) -> String {
107+
let with_v = if v == "" { base } else { base ++ " " ++ v };
108+
let with_s = if s == "" { with_v } else { with_v ++ " " ++ s };
109+
let with_l = if l == "" { with_s } else { with_s ++ " " ++ l };
110+
let with_e = if extra == "" { with_l } else { with_l ++ " " ++ extra };
111+
with_e
112+
}
113+
114+
// ── The builder ───────────────────────────────────────────────────────────────
115+
//
116+
// Returns an owned button element. The caller is responsible for mounting it
117+
// (via @affinescript/dom's `mount`) and ultimately for letting it drop, which
118+
// the runtime will detach from the live DOM.
119+
120+
fn make_button(props: own ButtonProps) -> own Element / IO {
121+
let busy: Bool = props.disabled || props.loading;
122+
123+
let classes: String = join_classes(
124+
"a11y-button",
125+
variant_class(props.variant),
126+
size_class(props.size),
127+
loading_class(props.loading),
128+
props.class_extra,
129+
);
130+
131+
let btn = createElement("button");
132+
setAttribute(btn, "type", props.button_type);
133+
setAttribute(btn, "class", classes);
134+
135+
if busy {
136+
setAttribute(btn, "disabled", "");
137+
setAttribute(btn, "aria-disabled", "true");
138+
}
139+
140+
if props.loading {
141+
setAttribute(btn, "aria-busy", "true");
142+
}
143+
144+
match props.aria_label {
145+
Some(label) => setAttribute(btn, "aria-label", label)
146+
None => ()
147+
}
148+
149+
match props.on_click {
150+
Some(handler) => addEventListener(btn, "click", handler)
151+
None => ()
152+
}
153+
154+
// Children, in order:
155+
// loading: spinner only (icons + text suppressed)
156+
// else: [icon_before?] text [icon_after?]
157+
if props.loading {
158+
let spinner = createElement("span");
159+
setAttribute(spinner, "class", "a11y-button__spinner");
160+
setAttribute(spinner, "aria-hidden", "true");
161+
appendChild(btn, spinner);
162+
} else {
163+
match props.icon_before {
164+
Some(icon) => {
165+
let wrap = createElement("span");
166+
setAttribute(wrap, "class", "a11y-button__icon-before");
167+
setAttribute(wrap, "aria-hidden", "true");
168+
appendChild(wrap, icon);
169+
appendChild(btn, wrap);
170+
}
171+
None => ()
172+
}
173+
}
174+
175+
// Text wrapper is always present (loading just hides icons, not the label
176+
// — preserves layout stability per WCAG 2.1.4.13 advisory).
177+
let text_wrap = createElement("span");
178+
setAttribute(text_wrap, "class", "a11y-button__text");
179+
appendChild(text_wrap, props.text);
180+
appendChild(btn, text_wrap);
181+
182+
if !props.loading {
183+
match props.icon_after {
184+
Some(icon) => {
185+
let wrap = createElement("span");
186+
setAttribute(wrap, "class", "a11y-button__icon-after");
187+
setAttribute(wrap, "aria-hidden", "true");
188+
appendChild(wrap, icon);
189+
appendChild(btn, wrap);
190+
}
191+
None => ()
192+
}
193+
}
194+
195+
btn
196+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
//
3+
// Public surface for @accessibility-everywhere/react.
4+
//
5+
// Migration status: Button is the AffineScript pilot per the migration-playbook.
6+
// Modal stays as Modal.tsx in this revision — its focus-trap + portal + scroll-
7+
// lock + escape-handler bundle needs deeper effect-modelling (see PORTING_NOTES
8+
// at the bottom of this file), and is the next session's work.
9+
10+
// Re-export Button surface
11+
type ButtonVariant = Primary | Secondary | Ghost | Danger
12+
type ButtonSize = Sm | Md | Lg
13+
14+
// Library version + WCAG conformance level (these are pure data; effect-free).
15+
let version: String = "1.0.0"
16+
let wcag_level: String = "AA"
17+
18+
// ── PORTING_NOTES for Modal (next session) ────────────────────────────────────
19+
//
20+
// Modal.tsx's React patterns and their planned AffineScript decomposition:
21+
//
22+
// * `useEffect` portal mounting → `own Portal` resource attached at
23+
// construction; dropped on unmount
24+
// * `useEffect` escape-key listener → `own KeyboardSubscription` bound
25+
// to the body, lifetime = modal
26+
// * `useEffect` focus trap (Tab/Shift-Tab) → same as above; one subscription
27+
// * `useEffect` scroll lock → `own BodyScrollLock` resource;
28+
// drop restores `body.style.overflow`
29+
// * `useEffect` previous-focus restoration → captured-once Element ref stored
30+
// on the `own Modal` record
31+
// * `useId` → effect `Id { fn fresh() -> String }`
32+
// * `useState(mounted)` → no equivalent; SSR-portal toggling
33+
// is irrelevant outside React
34+
//
35+
// In short: every "useEffect cleanup" becomes a Drop-on-own resource. The React
36+
// effect-with-cleanup idiom maps cleanly onto AffineScript ownership: when the
37+
// `own Modal` is dropped, all its sub-resources are released in declared order.

0 commit comments

Comments
 (0)