Skip to content

Commit 97d54fd

Browse files
hhkaosclaude
andcommitted
polish(editor): subtler remove button, proportional sub-field widths, slug/id as text
The advanced-block "×" no longer reserves padding on the whole field just to make room for itself — it floats over the corner instead, so removable fields (Description, Tags…) keep their full width. Organizer/Offer/CFP/ Source sub-fields now carry a size hint (s/m/l) so Price and Currency don't claim as much row width as Name or a URL. Slug and Id reuse the read-only- until-edit pattern already built for Lat/Lon, rendering as plain suggested text instead of an input box until "Edit" is clicked (the readonly CSS rule now also covers type="url", since Id is a url control). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DrGeVXHTNNdPi7MCgSageG
1 parent 9a03487 commit 97d54fd

3 files changed

Lines changed: 112 additions & 26 deletions

File tree

apps/editor/src/i18n/es.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,7 @@ export const es: Record<string, string> = {
360360
"ui.remove": "Eliminar",
361361
"ui.chips.placeholder": "Escribe para añadir…",
362362
"ui.enterCoordinatesManually": "Introducir coordenadas manualmente",
363+
"ui.editSuggested": "Editar",
363364
"ui.importGap": "No estaba en el ICS importado — rellénalo a mano si lo sabes.",
364365
"ui.instant.preview": "UTC: {iso} (introducido en {zone})",
365366
"ui.instant.yourDeviceTimezone": "la zona horaria de tu dispositivo",

apps/editor/src/ui/form.ts

Lines changed: 68 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,8 @@ interface Control {
181181
info?: string;
182182
/** Hidden unless the named sibling control's current value is one of `values`. */
183183
visibleWhen?: { key: StateKey; values: readonly string[] };
184+
/** Row-width hint relative to its siblings — short/fixed-shape values (a price, a 3-letter currency code) don't need as much room as a name or URL. Omit for the default (m). */
185+
size?: "s" | "m" | "l";
184186
}
185187

186188
interface FieldSpec {
@@ -461,17 +463,25 @@ const FIELD_SPECS: Record<string, FieldSpec> = {
461463
note: "Only for events accepting talk/workshop submissions.",
462464
info: "The one OTE field with no equivalent in ICS, RSS or plain schema.org — it exists because \"which conferences are still accepting proposals\" is a question only the organizer can answer today.",
463465
controls: [
464-
{ key: "cfpUrl", label: "URL", kind: "url", info: "Where speakers submit a proposal." },
466+
{
467+
key: "cfpUrl",
468+
label: "URL",
469+
kind: "url",
470+
size: "l",
471+
info: "Where speakers submit a proposal.",
472+
},
465473
{
466474
key: "cfpOpensAt",
467475
label: "Opens at",
468476
kind: "instant",
477+
size: "l",
469478
info: "When submissions start being accepted.",
470479
},
471480
{
472481
key: "cfpClosesAt",
473482
label: "Closes at",
474483
kind: "instant",
484+
size: "l",
475485
info: "Submission deadline — after this, cfpUrl should stop accepting new ones.",
476486
},
477487
{
@@ -529,24 +539,28 @@ const FIELD_SPECS: Record<string, FieldSpec> = {
529539
key: "sourceName",
530540
label: "Name",
531541
kind: "text",
542+
size: "l",
532543
info: "Where this data was imported from — a platform name (\"Meetup\") or organization.",
533544
},
534545
{
535546
key: "sourceUrl",
536547
label: "URL",
537548
kind: "url",
549+
size: "l",
538550
info: "The exact page or feed this event's data was read from.",
539551
},
540552
{
541553
key: "sourceLicense",
542554
label: "License",
543555
kind: "text",
556+
size: "s",
544557
info: "The license the SOURCE data was published under, if stated — not this file's own license (see License above).",
545558
},
546559
{
547560
key: "sourceRetrievedAt",
548561
label: "Retrieved at",
549562
kind: "instant",
563+
size: "l",
550564
info: "When this data was fetched — set automatically by the import flow; edit only if importing by hand.",
551565
},
552566
],
@@ -905,6 +919,8 @@ interface RepeaterItemField {
905919
info?: string;
906920
/** Hidden unless the named sibling field's current value (within the same row) is one of `values`. */
907921
visibleWhen?: { key: string; values: readonly string[] };
922+
/** Row-width hint relative to its siblings — see Control.size. Omit for the default (m). */
923+
size?: "s" | "m" | "l";
908924
}
909925

910926
interface RepeaterSpec {
@@ -927,13 +943,15 @@ const REPEATER_SPECS: Record<RepeaterKey, RepeaterSpec> = {
927943
key: "name",
928944
label: "Name",
929945
kind: "text",
946+
size: "l",
930947
info: "As attendees would recognize it — the group or person's usual name.",
931948
},
932949
{
933950
key: "url",
934951
label: "URL",
935952
kind: "url",
936953
placeholder: "https://…",
954+
size: "l",
937955
info: "Website or profile for this organizer. Leave empty if they don't have one.",
938956
},
939957
{
@@ -947,6 +965,7 @@ const REPEATER_SPECS: Record<RepeaterKey, RepeaterSpec> = {
947965
key: "type",
948966
label: "Type",
949967
kind: "select",
968+
size: "s",
950969
options: ["", "organization", "person"],
951970
info: "Organization or individual person — lets consumers pick the right icon or label.",
952971
},
@@ -972,52 +991,60 @@ const REPEATER_SPECS: Record<RepeaterKey, RepeaterSpec> = {
972991
label: "Name",
973992
kind: "text",
974993
placeholder: "General admission",
994+
size: "l",
975995
info: "What this tier is called — \"General admission\", \"Early bird\", \"Student\"…",
976996
},
977997
{
978998
key: "price",
979999
label: "Price",
9801000
kind: "number",
9811001
placeholder: "0",
1002+
size: "s",
9821003
info: "0 means free. Leave empty only when the price itself is unknown — that's not the same as free.",
9831004
},
9841005
{
9851006
key: "currency",
9861007
label: "Currency",
9871008
kind: "text",
9881009
placeholder: "EUR",
1010+
size: "s",
9891011
info: "ISO 4217 code (EUR, USD…) — expected alongside a non-zero price.",
9901012
},
9911013
{
9921014
key: "url",
9931015
label: "URL",
9941016
kind: "url",
1017+
size: "l",
9951018
info: "Where to buy or register for this specific tier.",
9961019
},
9971020
{
9981021
key: "availability",
9991022
label: "Availability",
10001023
kind: "select",
1024+
size: "s",
10011025
options: ["", "in-stock", "sold-out"],
10021026
info: "sold-out reveals a Waitlist URL below. Leave empty when unknown.",
10031027
},
10041028
{
10051029
key: "waitlistUrl",
10061030
label: "Waitlist URL",
10071031
kind: "url",
1032+
size: "l",
10081033
visibleWhen: { key: "availability", values: ["sold-out"] },
10091034
info: "Where interested people can join the waitlist once this tier sells out.",
10101035
},
10111036
{
10121037
key: "opensAt",
10131038
label: "Opens at",
10141039
kind: "instant",
1040+
size: "l",
10151041
info: "When this tier becomes available for purchase — not the event's own start.",
10161042
},
10171043
{
10181044
key: "closesAt",
10191045
label: "Closes at",
10201046
kind: "instant",
1047+
size: "l",
10211048
info: "When this tier stops being available (sale ends) — not the event's own end.",
10221049
},
10231050
],
@@ -1166,6 +1193,7 @@ function renderRepeaterItemControl(
11661193
if (describedBy) rendered.input.setAttribute("aria-describedby", describedBy);
11671194
if (!field.label) return rendered;
11681195
const wrap = document.createElement("div");
1196+
if (field.size) wrap.classList.add(`size-${field.size}`);
11691197
const label = document.createElement("label");
11701198
label.htmlFor = rendered.input.id;
11711199
label.textContent = t(`${i18nKey}.label`, field.label);
@@ -1197,6 +1225,7 @@ function renderRepeaterItemControl(
11971225

11981226
if (!field.label) return { element: input, input };
11991227
const wrap = document.createElement("div");
1228+
if (field.size) wrap.classList.add(`size-${field.size}`);
12001229
const label = document.createElement("label");
12011230
label.htmlFor = input.id;
12021231
label.textContent = t(`${i18nKey}.label`, field.label);
@@ -1816,6 +1845,7 @@ function renderControl(
18161845
rendered.input.dataset.key = control.key;
18171846
if (!control.label) return rendered;
18181847
const wrap = document.createElement("div");
1848+
if (control.size) wrap.classList.add(`size-${control.size}`);
18191849
const label = document.createElement("label");
18201850
label.htmlFor = rendered.input.id;
18211851
label.textContent = t(`control.${control.key}.label`, control.label);
@@ -1862,6 +1892,7 @@ function renderControl(
18621892

18631893
if (!control.label) return { element: input, input };
18641894
const wrap = document.createElement("div");
1895+
if (control.size) wrap.classList.add(`size-${control.size}`);
18651896
const label = document.createElement("label");
18661897
label.htmlFor = input.id;
18671898
label.textContent = t(`control.${control.key}.label`, control.label);
@@ -1932,23 +1963,7 @@ function renderField(
19321963
errorId = appendError(outer);
19331964
// The map is the primary way to set a position — typing raw decimal
19341965
// degrees by hand is the rare case, so it isn't invited by default.
1935-
// readOnly (not disabled) keeps the values focusable/copyable and
1936-
// still shows whatever the map writes via main.ts's existing sync.
1937-
for (const c of controls) {
1938-
if (c.input instanceof HTMLInputElement) c.input.readOnly = true;
1939-
}
1940-
const manualToggle = document.createElement("button");
1941-
manualToggle.type = "button";
1942-
manualToggle.className = "link-button";
1943-
manualToggle.textContent = t("ui.enterCoordinatesManually", "Enter coordinates manually");
1944-
manualToggle.addEventListener("click", () => {
1945-
for (const c of controls) {
1946-
if (c.input instanceof HTMLInputElement) c.input.readOnly = false;
1947-
}
1948-
controls[0]?.input.focus();
1949-
manualToggle.remove();
1950-
});
1951-
outer.append(manualToggle);
1966+
addManualToggle(outer, controls, t("ui.enterCoordinatesManually", "Enter coordinates manually"));
19521967
} else {
19531968
outer.append(label, row);
19541969
noteId = appendNote(outer, spec.note ? t(`field.${fieldId}.note`, spec.note) : undefined);
@@ -1967,6 +1982,12 @@ function renderField(
19671982
const errorId = appendError(field);
19681983
const describedBy = describedByOf(noteId, errorId);
19691984
if (describedBy) controls[0]?.input.setAttribute("aria-describedby", describedBy);
1985+
// Auto-suggested and rarely meant to be hand-edited — id's own tooltip
1986+
// says as much ("never change it after publishing"). Reads as proposed
1987+
// text, not an invitation to type, until Edit unlocks it.
1988+
if (fieldId === "slug" || fieldId === "id") {
1989+
addManualToggle(field, controls, t("ui.editSuggested", "Edit"));
1990+
}
19701991
return field;
19711992
}
19721993

@@ -2013,6 +2034,35 @@ function clearField(
20132034
for (const c of FIELD_SPECS[id].controls) onInput(c.key, c.kind === "checkbox" ? false : "");
20142035
}
20152036

2037+
/**
2038+
* Makes `controls` read-only (styled as plain text, not an input box — see
2039+
* `.field input[readonly]` in styles.css) and appends a small toggle that
2040+
* unlocks them for hand-editing. Used for values that already have a
2041+
* correct answer most of the time — map-driven coordinates, auto-suggested
2042+
* slug/id — so typing isn't the first thing invited.
2043+
*/
2044+
function addManualToggle(
2045+
container: HTMLElement,
2046+
controls: { input: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement }[],
2047+
toggleLabel: string,
2048+
): void {
2049+
for (const c of controls) {
2050+
if (c.input instanceof HTMLInputElement) c.input.readOnly = true;
2051+
}
2052+
const toggle = document.createElement("button");
2053+
toggle.type = "button";
2054+
toggle.className = "link-button";
2055+
toggle.textContent = toggleLabel;
2056+
toggle.addEventListener("click", () => {
2057+
for (const c of controls) {
2058+
if (c.input instanceof HTMLInputElement) c.input.readOnly = false;
2059+
}
2060+
controls[0]?.input.focus();
2061+
toggle.remove();
2062+
});
2063+
container.append(toggle);
2064+
}
2065+
20162066
/** Wraps `inner` with a "×" button styled like a repeater row's, calling `onRemove` when clicked. */
20172067
function wrapRemovable(inner: HTMLElement, label: string, onRemove: () => void): HTMLElement {
20182068
const block = document.createElement("div");

apps/editor/styles.css

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -531,19 +531,22 @@ select {
531531
box-shadow: 0 0 0 3px var(--accent-soft);
532532
}
533533

534-
/* Read-only until "Enter coordinates manually" (the geo Lat/Lon fields) —
535-
reads as plain text, not an editable box, until the toggle actually
536-
makes it one. Left/right padding drops to 0 so the value lines up with
537-
its own label above it instead of looking indented inside an empty box. */
538-
.field input[type="text"][readonly] {
534+
/* Read-only until its own "edit" toggle unlocks it — geo Lat/Lon (text) and
535+
the Metadata Slug/Id fields (Id is a "url" input) all use this. Reads as
536+
plain text, not an editable box, until then. Left/right padding drops to
537+
0 so the value lines up with its own label above it instead of looking
538+
indented inside an empty box. */
539+
.field input[type="text"][readonly],
540+
.field input[type="url"][readonly] {
539541
border-color: transparent;
540542
background: none;
541543
padding-left: 0;
542544
padding-right: 0;
543545
cursor: default;
544546
}
545547

546-
.field input[type="text"][readonly]:focus-visible {
548+
.field input[type="text"][readonly]:focus-visible,
549+
.field input[type="url"][readonly]:focus-visible {
547550
border-color: transparent;
548551
box-shadow: none;
549552
outline: 2px solid var(--accent);
@@ -591,6 +594,17 @@ select {
591594
flex: 1;
592595
}
593596

597+
/* Sub-field width hints (`size-s/m/l`, set per control in FIELD_SPECS/
598+
REPEATER_SPECS) — a Price or Currency shouldn't claim as much row width
599+
as a Name or URL just because they're neighbors. Unset = default (m). */
600+
.field.pair > div.size-s {
601+
flex: 0 1 90px;
602+
}
603+
604+
.field.pair > div.size-l {
605+
flex: 2 1 220px;
606+
}
607+
594608
.field.pair label {
595609
font-weight: 400;
596610
font-size: 0.8rem;
@@ -779,6 +793,15 @@ select {
779793
flex: 1 1 130px;
780794
}
781795

796+
/* Same size-s/m/l hint as .field.pair (see there for why). */
797+
.repeater-item-fields > div.size-s {
798+
flex: 0 1 90px;
799+
}
800+
801+
.repeater-item-fields > div.size-l {
802+
flex: 2 1 200px;
803+
}
804+
782805
.repeater-item-fields label {
783806
display: block;
784807
font-weight: 400;
@@ -844,10 +867,22 @@ select {
844867
margin: 0.5rem 0;
845868
}
846869

847-
/* Right padding leaves room for .repeater-remove, same as .repeater-item. */
870+
/* No reserved padding here (unlike .repeater-item) — the field keeps its
871+
full width and the "×" just floats over the corner, low-key until
872+
hovered, since it lands on the label row rather than the input itself. */
848873
.advanced-block {
849874
position: relative;
850-
padding-right: 2.75rem;
875+
}
876+
877+
.advanced-block > .repeater-remove {
878+
min-width: 1.75rem;
879+
min-height: 1.75rem;
880+
font-size: 1rem;
881+
opacity: 0.55;
882+
}
883+
884+
.advanced-block > .repeater-remove:hover {
885+
opacity: 1;
851886
}
852887

853888
/* A nudge, not a different affordance — same shape/size as a plain

0 commit comments

Comments
 (0)