studio-engine 0.53.1 → 0.54.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -0
- data/app/views/studio/fields/_date_of_birth.html.erb +80 -0
- data/app/views/studio/modals/blocks/_age_verify.html.erb +5 -29
- data/app/views/studio/profiles/_birthday_fields.html.erb +13 -182
- data/app/views/studio/profiles/_birthday_picker_script.html.erb +70 -171
- data/lib/studio/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3419d6ec43e811060b90f6e628feabc249bb6790f9b911c258512ca2fa13f87e
|
|
4
|
+
data.tar.gz: e91ce80dcd5465ce9660091cb7570327322e1a9aff79d115f4ba0116a7da4a38
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 308d06c9339512b02da78a7c5877134a91b38f20cb89f6e4692b1f734839250a03d15fd1cdea463255ffe49b2e7a675d8f95624cbaf1e4f275ae4ddc83e7b480
|
|
7
|
+
data.tar.gz: f920799c11d5fdb4ad8757e4afedea52592dcce1d103e7a53f5e666cf62c386994fc9d2d0bb284ea3530840291c5e375fb2f5d279e537b4253c24a60838319e8
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,42 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`studio/fields/_date_of_birth`** — the engine's one date-of-birth field,
|
|
10
|
+
three selects (Month / Day / Year). Rendered by
|
|
11
|
+
`studio/modals/blocks/_age_verify` and by `/profile/edit`'s birthday row.
|
|
12
|
+
|
|
13
|
+
**It was already duplicated**, which is the real argument for extracting it:
|
|
14
|
+
those selects lived in the age-verify block, and turf-monster forked a second
|
|
15
|
+
copy into its own `modals/_age_verify`. Two copies of one field is how they
|
|
16
|
+
drift.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- **`/profile/edit`'s birthday drops the calendar popover** for that shared
|
|
21
|
+
field (operator's call). The calendar shipped **four defects in three days**:
|
|
22
|
+
its day grid used `grid-cols-7`, which exists in no consumer bundle, so the
|
|
23
|
+
weekday letters stacked into one column and it grew to the height of the page;
|
|
24
|
+
a flipped popover positioned from a hardcoded 340px height when the real one
|
|
25
|
+
is ~245px; `place()` re-decided the side on every scroll so it snapped
|
|
26
|
+
mid-scroll; and it came back open after a browser Back.
|
|
27
|
+
|
|
28
|
+
Three selects have none of those failure modes — no position, no flip, no
|
|
29
|
+
popover, no scroll listener. Their utilities are also **proven across the
|
|
30
|
+
fleet**: `grid-cols-3` and `label-upper` are present in all three consumers'
|
|
31
|
+
compiled bundles, which `grid-cols-7` never was.
|
|
32
|
+
|
|
33
|
+
The **no-JavaScript path is unchanged**: the native `<input type="date">` still
|
|
34
|
+
renders under `template x-if`, so only one branch is ever in the DOM.
|
|
35
|
+
|
|
36
|
+
Nine browser specs retired with the popover — all of them about positioning —
|
|
37
|
+
and six replaced them, testing where a select-based field can actually be
|
|
38
|
+
wrong: the day list following the month (30 in April, 29 in a leap February),
|
|
39
|
+
clearing a day the new month lost, refusing a partial date, and a year list
|
|
40
|
+
that stops at this year.
|
|
41
|
+
|
|
42
|
+
|
|
7
43
|
### Changed
|
|
8
44
|
|
|
9
45
|
- **Each `/profile` row is its own card.** They were rendered inside one merged
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<%# A date of birth, as three selects. The engine's one DOB field.
|
|
2
|
+
|
|
3
|
+
Locals (all optional):
|
|
4
|
+
label_month / label_day / label_year — the field labels. Defaults below.
|
|
5
|
+
on_change — Alpine expression run when ANY of the three changes, so a call
|
|
6
|
+
site can recompute whatever it derives. Defaults to the
|
|
7
|
+
age-gate's onMonthChange(), which every current caller has.
|
|
8
|
+
|
|
9
|
+
ONE HOOK FOR ALL THREE, where age_verify used onMonthChange()
|
|
10
|
+
for month/year and `error = ''` for day. That is not a
|
|
11
|
+
behaviour change: onMonthChange() clears an out-of-range day
|
|
12
|
+
and then clears the error, and a day just chosen FROM
|
|
13
|
+
dayOptions is never out of range — so on the day select it
|
|
14
|
+
reduces to exactly `error = ''`. Checked rather than assumed,
|
|
15
|
+
because an extraction that quietly alters a call site is worse
|
|
16
|
+
than no extraction.
|
|
17
|
+
|
|
18
|
+
WHAT THE ENCLOSING ALPINE COMPONENT MUST SUPPLY. This partial is markup
|
|
19
|
+
only; it binds to the component it is rendered inside:
|
|
20
|
+
|
|
21
|
+
month, day, year — x-model targets
|
|
22
|
+
months — [{ n: 1, label: "January" }, …]
|
|
23
|
+
dayOptions — the days valid for the selected month/year
|
|
24
|
+
years — the year range this caller offers
|
|
25
|
+
|
|
26
|
+
EXTRACTED BECAUSE IT WAS ALREADY DUPLICATED: these selects lived in
|
|
27
|
+
studio/modals/blocks/_age_verify, and turf-monster forked a second copy into
|
|
28
|
+
its own modals/_age_verify. Two copies of one field is how they drift.
|
|
29
|
+
|
|
30
|
+
AND BECAUSE IT REPLACED SOMETHING WORSE. /profile/edit used a custom calendar
|
|
31
|
+
popover, which shipped four defects in three days — its grid collapsed in
|
|
32
|
+
every consumer (grid-cols-7 exists in no consumer bundle), a flipped popover
|
|
33
|
+
used a hardcoded height and landed ~95px wrong, place() re-decided the side on
|
|
34
|
+
every scroll so it snapped mid-scroll, and it came back OPEN after a browser
|
|
35
|
+
Back. Three selects have none of those failure modes: no positioning, no
|
|
36
|
+
flip, no popover, no scroll listener.
|
|
37
|
+
|
|
38
|
+
ITS UTILITIES ARE PROVEN, which the calendar's were not. Measured across the
|
|
39
|
+
fleet: grid-cols-3 and label-upper are present in turf-monster,
|
|
40
|
+
mcritchie-studio AND mcritchie-industries' compiled bundles. The engine ships
|
|
41
|
+
a prebuilt bundle, so a utility exists in a consumer only if that consumer
|
|
42
|
+
already emitted it — this field's do. %>
|
|
43
|
+
<%
|
|
44
|
+
label_month = local_assigns.fetch(:label_month, "Month")
|
|
45
|
+
label_day = local_assigns.fetch(:label_day, "Day")
|
|
46
|
+
label_year = local_assigns.fetch(:label_year, "Year")
|
|
47
|
+
on_change = local_assigns.fetch(:on_change, "onMonthChange()")
|
|
48
|
+
%>
|
|
49
|
+
<div class="grid grid-cols-3 gap-2">
|
|
50
|
+
<div>
|
|
51
|
+
<label class="label-upper block mb-1"><%= label_month %></label>
|
|
52
|
+
<select x-model="month" @change="<%= on_change %>"
|
|
53
|
+
class="input-field !px-3 !py-2.5 text-sm appearance-none cursor-pointer">
|
|
54
|
+
<option value="" disabled><%= label_month %></option>
|
|
55
|
+
<template x-for="m in months" :key="m.n">
|
|
56
|
+
<option :value="m.n" x-text="m.label"></option>
|
|
57
|
+
</template>
|
|
58
|
+
</select>
|
|
59
|
+
</div>
|
|
60
|
+
<div>
|
|
61
|
+
<label class="label-upper block mb-1"><%= label_day %></label>
|
|
62
|
+
<select x-model="day" @change="<%= on_change %>"
|
|
63
|
+
class="input-field !px-3 !py-2.5 text-sm appearance-none cursor-pointer">
|
|
64
|
+
<option value="" disabled><%= label_day %></option>
|
|
65
|
+
<template x-for="d in dayOptions" :key="d">
|
|
66
|
+
<option :value="d" x-text="d"></option>
|
|
67
|
+
</template>
|
|
68
|
+
</select>
|
|
69
|
+
</div>
|
|
70
|
+
<div>
|
|
71
|
+
<label class="label-upper block mb-1"><%= label_year %></label>
|
|
72
|
+
<select x-model="year" @change="<%= on_change %>"
|
|
73
|
+
class="input-field !px-3 !py-2.5 text-sm appearance-none cursor-pointer">
|
|
74
|
+
<option value="" disabled><%= label_year %></option>
|
|
75
|
+
<template x-for="y in years" :key="y">
|
|
76
|
+
<option :value="y" x-text="y"></option>
|
|
77
|
+
</template>
|
|
78
|
+
</select>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
@@ -49,35 +49,11 @@
|
|
|
49
49
|
<%= intro %>
|
|
50
50
|
</p>
|
|
51
51
|
|
|
52
|
-
<%# Month / Day / Year
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
<option value="" disabled>Month</option>
|
|
58
|
-
<template x-for="m in months" :key="m.n">
|
|
59
|
-
<option :value="m.n" x-text="m.label"></option>
|
|
60
|
-
</template>
|
|
61
|
-
</select>
|
|
62
|
-
</div>
|
|
63
|
-
<div>
|
|
64
|
-
<label class="label-upper block mb-1">Day</label>
|
|
65
|
-
<select x-model="day" @change="error = ''" class="input-field !px-3 !py-2.5 text-sm appearance-none cursor-pointer">
|
|
66
|
-
<option value="" disabled>Day</option>
|
|
67
|
-
<template x-for="d in dayOptions" :key="d">
|
|
68
|
-
<option :value="d" x-text="d"></option>
|
|
69
|
-
</template>
|
|
70
|
-
</select>
|
|
71
|
-
</div>
|
|
72
|
-
<div>
|
|
73
|
-
<label class="label-upper block mb-1">Year</label>
|
|
74
|
-
<select x-model="year" @change="onMonthChange()" class="input-field !px-3 !py-2.5 text-sm appearance-none cursor-pointer">
|
|
75
|
-
<option value="" disabled>Year</option>
|
|
76
|
-
<template x-for="y in years" :key="y">
|
|
77
|
-
<option :value="y" x-text="y"></option>
|
|
78
|
-
</template>
|
|
79
|
-
</select>
|
|
80
|
-
</div>
|
|
52
|
+
<%# Month / Day / Year — the engine's one DOB field, shared with the profile
|
|
53
|
+
page's birthday row. It lived here first and turf-monster forked a second
|
|
54
|
+
copy; extracting it is what stops the two drifting. %>
|
|
55
|
+
<div class="mb-4">
|
|
56
|
+
<%= render "studio/fields/date_of_birth" %>
|
|
81
57
|
</div>
|
|
82
58
|
|
|
83
59
|
<%# Live too-young hint — shown once a full date is picked and it fails the
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<%# Birthday —
|
|
1
|
+
<%# Birthday — THREE SELECTS over three integer columns.
|
|
2
2
|
|
|
3
3
|
Locals: user (required).
|
|
4
4
|
|
|
@@ -9,23 +9,23 @@
|
|
|
9
9
|
(needs month and day, and no year at all). A single date column answers the
|
|
10
10
|
second one badly.
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
THE SAME FIELD THE AGE GATE USES (operator's call, 2026-08-15). It renders
|
|
13
|
+
studio/fields/_date_of_birth, which studio/modals/blocks/_age_verify also
|
|
14
|
+
renders — one field, two surfaces. It replaced a custom calendar popover that
|
|
15
|
+
shipped four defects in three days; see the component script for the list.
|
|
14
16
|
|
|
15
17
|
TWO INPUTS, ONE NAME, AND ONLY EVER ONE IN THE DOM. `<template x-if>` is what
|
|
16
18
|
makes that true rather than a claim: the branch Alpine does not take is never
|
|
17
19
|
instantiated, so the form can never carry two `profile[birthday]` fields and
|
|
18
20
|
the server can never receive the wrong one. Toggling with x-show would leave
|
|
19
21
|
BOTH present and submit the hidden one's value too — the last duplicate wins
|
|
20
|
-
in Rack's params
|
|
22
|
+
in Rack's params.
|
|
21
23
|
|
|
22
|
-
no JS — the native `<input type="date">`.
|
|
24
|
+
no JS — the native `<input type="date">`. Not a downgrade: it is the
|
|
23
25
|
control every platform already knows how to render, and the reason
|
|
24
26
|
a JS-less page can still set a birthday at all.
|
|
25
|
-
JS — the
|
|
27
|
+
JS — the three selects. `alpine` comes from the enclosing
|
|
26
28
|
studioProfileForm and is true only once that component has booted.
|
|
27
|
-
|
|
28
|
-
The operator asked for a calendar rather than an open field (2026-08-15).
|
|
29
29
|
%>
|
|
30
30
|
<label class="block text-sm text-secondary mb-2 font-medium" for="profile_birthday">Date of birth</label>
|
|
31
31
|
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
value = (y.present? && m.present? && d.present?) ? format("%04d-%02d-%02d", y, m, d) : nil
|
|
37
37
|
%>
|
|
38
38
|
|
|
39
|
-
<div x-data="
|
|
39
|
+
<div x-data="studioBirthdayFields('<%= value %>')">
|
|
40
40
|
<%# --- the JS-less path ------------------------------------------------- %>
|
|
41
41
|
<template x-if="!alpine">
|
|
42
42
|
<input type="date" name="profile[birthday]" id="profile_birthday"
|
|
@@ -44,187 +44,18 @@
|
|
|
44
44
|
autocomplete="bday" max="<%= Date.current.iso8601 %>">
|
|
45
45
|
</template>
|
|
46
46
|
|
|
47
|
-
<%# --- the
|
|
47
|
+
<%# --- the three selects -------------------------------------------------- %>
|
|
48
48
|
<template x-if="alpine">
|
|
49
49
|
<div>
|
|
50
|
-
<%# The value the form actually submits. The
|
|
50
|
+
<%# The value the form actually submits. The selects write here; nothing
|
|
51
51
|
types into it. %>
|
|
52
|
-
<input type="hidden" name="profile[birthday]" :value="
|
|
53
|
-
|
|
54
|
-
<button type="button" x-ref="trigger" @click="toggle()"
|
|
55
|
-
@keydown.escape="open = false"
|
|
56
|
-
:aria-expanded="open ? 'true' : 'false'"
|
|
57
|
-
aria-haspopup="dialog"
|
|
58
|
-
class="input-field w-full text-left flex items-center justify-between gap-2">
|
|
59
|
-
<span :class="displayValue ? 'text-heading' : 'text-muted'"
|
|
60
|
-
x-text="displayValue || 'Select your date of birth'"></span>
|
|
61
|
-
<svg class="w-4 h-4 text-muted flex-shrink-0" fill="none" stroke="currentColor"
|
|
62
|
-
stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true">
|
|
63
|
-
<path stroke-linecap="round" stroke-linejoin="round"
|
|
64
|
-
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"/>
|
|
65
|
-
</svg>
|
|
66
|
-
</button>
|
|
67
|
-
|
|
68
|
-
<div x-show="open" x-cloak
|
|
69
|
-
x-ref="popover"
|
|
70
|
-
@click.outside="open = false"
|
|
71
|
-
@keydown.escape.window="open = false"
|
|
72
|
-
@scroll.window="place()" @resize.window="place()"
|
|
73
|
-
role="dialog" aria-label="Choose your date of birth"
|
|
74
|
-
class="studio-birthday-popover"
|
|
75
|
-
:style="'top:' + top + 'px; left:' + left + 'px; min-width:' + width + 'px;'">
|
|
76
|
-
|
|
77
|
-
<%# Month and year JUMP rather than step. Stepping from today to 1985 is
|
|
78
|
-
about four hundred clicks, which is the whole reason this is not the
|
|
79
|
-
contest picker. %>
|
|
80
|
-
<div class="studio-birthday-nav">
|
|
81
|
-
<select x-model.number="viewMonth" class="input-field studio-birthday-month"
|
|
82
|
-
aria-label="Month">
|
|
83
|
-
<template x-for="(name, i) in months" :key="name">
|
|
84
|
-
<option :value="i" x-text="name"></option>
|
|
85
|
-
</template>
|
|
86
|
-
</select>
|
|
87
|
-
<select x-model.number="viewYear" class="input-field studio-birthday-year"
|
|
88
|
-
aria-label="Year">
|
|
89
|
-
<template x-for="y in years" :key="y">
|
|
90
|
-
<option :value="y" x-text="y"></option>
|
|
91
|
-
</template>
|
|
92
|
-
</select>
|
|
93
|
-
</div>
|
|
94
|
-
|
|
95
|
-
<div class="studio-birthday-grid studio-birthday-weekdays">
|
|
96
|
-
<template x-for="(wd, i) in weekdays" :key="i">
|
|
97
|
-
<div x-text="wd"></div>
|
|
98
|
-
</template>
|
|
99
|
-
</div>
|
|
52
|
+
<input type="hidden" name="profile[birthday]" :value="isoValue">
|
|
100
53
|
|
|
101
|
-
|
|
102
|
-
<template x-for="(cell, i) in calDays()" :key="i">
|
|
103
|
-
<div>
|
|
104
|
-
<template x-if="cell">
|
|
105
|
-
<button type="button" @click="pick(cell)" :disabled="isFuture(cell)"
|
|
106
|
-
:class="isSelected(cell) && 'is-selected'"
|
|
107
|
-
class="studio-birthday-day"
|
|
108
|
-
x-text="cell"></button>
|
|
109
|
-
</template>
|
|
110
|
-
</div>
|
|
111
|
-
</template>
|
|
112
|
-
</div>
|
|
113
|
-
|
|
114
|
-
<%# A birthday already set is the only case where clearing is meaningful,
|
|
115
|
-
and without this the calendar is a one-way door — every day cell sets
|
|
116
|
-
a value and none of them unset one. %>
|
|
117
|
-
<div class="mt-3 flex justify-end" x-show="value">
|
|
118
|
-
<button type="button" @click="clear()"
|
|
119
|
-
class="text-xs text-secondary hover:text-heading transition">
|
|
120
|
-
Clear
|
|
121
|
-
</button>
|
|
122
|
-
</div>
|
|
123
|
-
</div>
|
|
54
|
+
<%= render "studio/fields/date_of_birth" %>
|
|
124
55
|
</div>
|
|
125
56
|
</template>
|
|
126
57
|
</div>
|
|
127
58
|
|
|
128
59
|
<p class="text-muted text-xs mt-2">Stored as day, month and year — we use it for birthdays and age checks.</p>
|
|
129
60
|
|
|
130
|
-
<style>
|
|
131
|
-
/* EVERY RULE BELOW IS OWNED CSS, NOT TAILWIND UTILITIES, and that is not a
|
|
132
|
-
style preference — it is the bug the operator reported.
|
|
133
|
-
|
|
134
|
-
The engine ships a PREBUILT bundle to consumers. A Tailwind utility exists in
|
|
135
|
-
a consuming app only if that app's OWN views already emitted it, because its
|
|
136
|
-
build scans its own source, not the gem's. This calendar first shipped using
|
|
137
|
-
`grid grid-cols-7`, and `grid-cols-7` is rare enough that NO consumer had
|
|
138
|
-
ever emitted it: measured in mcritchie-studio's compiled bundle, zero
|
|
139
|
-
occurrences. With no grid, the seven weekday letters stacked into a single
|
|
140
|
-
vertical column and the popover grew to the height of the page.
|
|
141
|
-
`hover:bg-surface-alt` was missing for the same reason.
|
|
142
|
-
|
|
143
|
-
The same note is on studio/profiles/_identity_styles for the same reason.
|
|
144
|
-
|
|
145
|
-
AND THE BROWSER LANE COULD NOT SEE IT. e2e/tailwind_input.css carries
|
|
146
|
-
`@source "../app/views"`, so the lane compiles the engine's OWN views and
|
|
147
|
-
emits grid-cols-7 — every spec passed against a page no consumer renders.
|
|
148
|
-
That blindness is recorded in docs/E2E_LANE.md; the fix here is to stop
|
|
149
|
-
depending on the utility at all. */
|
|
150
|
-
.studio-birthday-nav {
|
|
151
|
-
display: flex;
|
|
152
|
-
align-items: center;
|
|
153
|
-
gap: 0.5rem;
|
|
154
|
-
margin-bottom: 0.75rem;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
.studio-birthday-month { flex: 1 1 auto; }
|
|
158
|
-
|
|
159
|
-
.studio-birthday-month,
|
|
160
|
-
.studio-birthday-year {
|
|
161
|
-
font-size: 0.875rem;
|
|
162
|
-
padding-top: 0.25rem;
|
|
163
|
-
padding-bottom: 0.25rem;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/* THE ONE THAT BROKE. Seven columns, owned outright. */
|
|
167
|
-
.studio-birthday-grid {
|
|
168
|
-
display: grid;
|
|
169
|
-
grid-template-columns: repeat(7, minmax(0, 1fr));
|
|
170
|
-
text-align: center;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
.studio-birthday-weekdays {
|
|
174
|
-
margin-bottom: 0.25rem;
|
|
175
|
-
font-size: 0.75rem;
|
|
176
|
-
font-weight: 500;
|
|
177
|
-
color: var(--color-text-muted, #9ca3af);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
.studio-birthday-weekdays > div { padding: 0.25rem 0; }
|
|
181
|
-
|
|
182
|
-
.studio-birthday-day {
|
|
183
|
-
width: 2rem;
|
|
184
|
-
height: 2rem;
|
|
185
|
-
border-radius: 9999px;
|
|
186
|
-
font-size: 0.875rem;
|
|
187
|
-
color: var(--color-text-body, inherit);
|
|
188
|
-
transition: background-color 150ms ease, color 150ms ease;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
.studio-birthday-day:hover:not(:disabled) {
|
|
192
|
-
background: var(--color-surface-alt, rgba(127, 127, 127, 0.15));
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
.studio-birthday-day:disabled {
|
|
196
|
-
opacity: 0.4;
|
|
197
|
-
cursor: not-allowed;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
.studio-birthday-day.is-selected {
|
|
201
|
-
background: var(--color-primary);
|
|
202
|
-
color: #fff;
|
|
203
|
-
font-weight: 700;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/* FIXED, not absolute, for TWO reasons — and the second is the one a spec can
|
|
207
|
-
actually catch.
|
|
208
|
-
|
|
209
|
-
1. Clipping. The edit page's rows sit inside `card p-0 overflow-hidden`. An
|
|
210
|
-
absolutely-positioned popover only escapes that clip while NO ancestor is
|
|
211
|
-
positioned; the day a consumer's card grows a `position: relative` it is
|
|
212
|
-
clipped out of sight, with perfectly correct markup.
|
|
213
|
-
2. Anchoring. place() writes VIEWPORT coordinates from the trigger's rect.
|
|
214
|
-
Fixed keeps them meaningful as the page scrolls; absolute reinterprets
|
|
215
|
-
them as document coordinates and the popover drifts by the scroll offset.
|
|
216
|
-
|
|
217
|
-
At scroll 0 the two are indistinguishable, which is why the spec that proves
|
|
218
|
-
this scrolls first. */
|
|
219
|
-
.studio-birthday-popover {
|
|
220
|
-
position: fixed;
|
|
221
|
-
z-index: 40;
|
|
222
|
-
background: var(--color-surface);
|
|
223
|
-
border: 1px solid var(--color-border-subtle);
|
|
224
|
-
border-radius: 0.75rem;
|
|
225
|
-
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.25);
|
|
226
|
-
padding: 0.75rem;
|
|
227
|
-
}
|
|
228
|
-
</style>
|
|
229
|
-
|
|
230
61
|
<%= render "studio/profiles/birthday_picker_script" %>
|
|
@@ -1,40 +1,48 @@
|
|
|
1
|
-
<%# The birthday
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
1
|
+
<%# The birthday field's Alpine component.
|
|
2
|
+
|
|
3
|
+
Supplies exactly what studio/fields/_date_of_birth binds to — month, day,
|
|
4
|
+
year, months, dayOptions, years, onMonthChange() — plus the one thing the
|
|
5
|
+
profile page needs on top: it writes the assembled ISO date into the
|
|
6
|
+
enclosing studioProfileForm's `fields.birthday`, which is what raises the
|
|
7
|
+
save bar.
|
|
8
|
+
|
|
9
|
+
THIS REPLACED A CALENDAR POPOVER, and the replacement is the point. That
|
|
10
|
+
component shipped four defects in three days:
|
|
11
|
+
|
|
12
|
+
· its day grid used `grid-cols-7`, which exists in NO consumer bundle, so
|
|
13
|
+
the seven weekday letters stacked into one column and the popover grew to
|
|
14
|
+
the height of the page;
|
|
15
|
+
· a flipped popover positioned itself from a hardcoded 340px height when
|
|
16
|
+
the real popover is ~245px, landing ~95px too high;
|
|
17
|
+
· place() ran on every scroll and RE-DECIDED the side, so it snapped
|
|
18
|
+
between above and below mid-scroll — a 254px jump between two 60px steps;
|
|
19
|
+
· and it came back OPEN after a browser Back.
|
|
20
|
+
|
|
21
|
+
Three selects have none of those failure modes. There is no position, no
|
|
22
|
+
flip, no popover, and no scroll listener — so there is nothing to place
|
|
23
|
+
wrongly, nothing to re-decide, and nothing to restore open.
|
|
21
24
|
%>
|
|
22
25
|
<script>
|
|
23
|
-
window.
|
|
26
|
+
window.studioBirthdayFields = function (initial) {
|
|
27
|
+
var parts = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(initial || ""));
|
|
28
|
+
|
|
24
29
|
return {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
year: parts ? String(+parts[1]) : "",
|
|
31
|
+
month: parts ? String(+parts[2]) : "",
|
|
32
|
+
day: parts ? String(+parts[3]) : "",
|
|
33
|
+
|
|
34
|
+
months: [
|
|
35
|
+
{ n: 1, label: "January" }, { n: 2, label: "February" },
|
|
36
|
+
{ n: 3, label: "March" }, { n: 4, label: "April" },
|
|
37
|
+
{ n: 5, label: "May" }, { n: 6, label: "June" },
|
|
38
|
+
{ n: 7, label: "July" }, { n: 8, label: "August" },
|
|
39
|
+
{ n: 9, label: "September" }, { n: 10, label: "October" },
|
|
40
|
+
{ n: 11, label: "November" }, { n: 12, label: "December" }
|
|
41
|
+
],
|
|
35
42
|
|
|
36
43
|
// Oldest year offered. 120 covers every living person with room to spare;
|
|
37
|
-
// the
|
|
44
|
+
// the newest is THIS year, because a birthday cannot be in the future and
|
|
45
|
+
// the cheapest way to say so is not to offer one.
|
|
38
46
|
get years() {
|
|
39
47
|
var now = new Date().getFullYear();
|
|
40
48
|
var out = [];
|
|
@@ -42,153 +50,44 @@
|
|
|
42
50
|
return out;
|
|
43
51
|
},
|
|
44
52
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
this.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
// current year means everyone starts 30+ years from where they are going.
|
|
57
|
-
syncViewFromValue() {
|
|
58
|
-
var parts = this.parse(this.value);
|
|
59
|
-
var now = new Date();
|
|
60
|
-
this.viewYear = parts ? parts.y : now.getFullYear() - 30;
|
|
61
|
-
this.viewMonth = parts ? parts.m - 1 : 0;
|
|
53
|
+
// The days that exist in the chosen month — 30 in April, and 29 in a
|
|
54
|
+
// February only when the chosen year is a leap year. Day 0 of the NEXT
|
|
55
|
+
// month is the last day of this one, which is the standard trick and
|
|
56
|
+
// handles the leap rule without one being written down.
|
|
57
|
+
get dayOptions() {
|
|
58
|
+
var m = parseInt(this.month, 10);
|
|
59
|
+
var y = parseInt(this.year, 10) || 2000; // a leap year, so Feb offers 29 before a year is picked
|
|
60
|
+
if (!m) return Array.from({ length: 31 }, function (_, i) { return i + 1; });
|
|
61
|
+
|
|
62
|
+
var count = new Date(y, m, 0).getDate();
|
|
63
|
+
return Array.from({ length: count }, function (_, i) { return i + 1; });
|
|
62
64
|
},
|
|
63
65
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (!m) return null;
|
|
67
|
-
return { y: +m[1], m: +m[2], d: +m[3] };
|
|
66
|
+
get complete() {
|
|
67
|
+
return !!(this.year && this.month && this.day);
|
|
68
68
|
},
|
|
69
69
|
|
|
70
|
-
get
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
get isoValue() {
|
|
71
|
+
if (!this.complete) return "";
|
|
72
|
+
return this.year + "-" +
|
|
73
|
+
String(this.month).padStart(2, "0") + "-" +
|
|
74
|
+
String(this.day).padStart(2, "0");
|
|
74
75
|
},
|
|
75
76
|
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
for (var d = 1; d <= count; d++) cells.push(d);
|
|
83
|
-
return cells;
|
|
77
|
+
// Named to match what the shared field binds to. Clears a day that the new
|
|
78
|
+
// month or year does not have — pick 31 January then switch to February and
|
|
79
|
+
// the 31st stops existing — then publishes.
|
|
80
|
+
onMonthChange() {
|
|
81
|
+
if (this.day && parseInt(this.day, 10) > this.dayOptions.length) this.day = "";
|
|
82
|
+
this.publish();
|
|
84
83
|
},
|
|
85
84
|
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
return candidate > today;
|
|
93
|
-
},
|
|
94
|
-
|
|
95
|
-
isSelected(day) {
|
|
96
|
-
var p = this.parse(this.value);
|
|
97
|
-
return !!p && p.y === this.viewYear && p.m === this.viewMonth + 1 && p.d === day;
|
|
98
|
-
},
|
|
99
|
-
|
|
100
|
-
pick(day) {
|
|
101
|
-
if (this.isFuture(day)) return;
|
|
102
|
-
var mm = String(this.viewMonth + 1).padStart(2, "0");
|
|
103
|
-
var dd = String(day).padStart(2, "0");
|
|
104
|
-
this.value = this.viewYear + "-" + mm + "-" + dd;
|
|
105
|
-
|
|
106
|
-
// `fields` belongs to the enclosing studioProfileForm component. Alpine's
|
|
107
|
-
// scope chain makes it reachable from here, and it is the SAME object, so
|
|
108
|
-
// assigning through it is what raises the save bar. Without this line the
|
|
109
|
-
// picker would set a value the dirty check never notices.
|
|
110
|
-
if (this.fields) this.fields.birthday = this.value;
|
|
111
|
-
|
|
112
|
-
this.open = false;
|
|
113
|
-
},
|
|
114
|
-
|
|
115
|
-
clear() {
|
|
116
|
-
this.value = "";
|
|
117
|
-
if (this.fields) this.fields.birthday = "";
|
|
118
|
-
this.syncViewFromValue();
|
|
119
|
-
},
|
|
120
|
-
|
|
121
|
-
toggle() {
|
|
122
|
-
this.open = !this.open;
|
|
123
|
-
if (!this.open) return;
|
|
124
|
-
|
|
125
|
-
this.syncViewFromValue();
|
|
126
|
-
|
|
127
|
-
// Placed twice, deliberately. The first call stops the popover appearing
|
|
128
|
-
// at 0,0 for a frame; the second is the one that can MEASURE, because
|
|
129
|
-
// offsetHeight is 0 while x-show still has the element hidden — and it is
|
|
130
|
-
// also the only place the SIDE is chosen, for the life of this open.
|
|
131
|
-
this.place();
|
|
132
|
-
this.$nextTick(function () {
|
|
133
|
-
this.chooseSide();
|
|
134
|
-
this.place();
|
|
135
|
-
}.bind(this));
|
|
136
|
-
},
|
|
137
|
-
|
|
138
|
-
// Placed from the trigger's rect because the popover is fixed. Flips above
|
|
139
|
-
// the trigger when there is not room below, so it never opens off-screen on
|
|
140
|
-
// a short viewport.
|
|
141
|
-
//
|
|
142
|
-
// THE HEIGHT IS MEASURED, NOT GUESSED, and that distinction is the bug the
|
|
143
|
-
// operator reported. This used a hardcoded `estimatedHeight = 340` for both
|
|
144
|
-
// the decision AND the flipped coordinate. The real popover is about 245px,
|
|
145
|
-
// so a flip landed ~95px too high and the calendar floated visibly detached
|
|
146
|
-
// from its field — and since scroll re-runs this, it kept jumping. The
|
|
147
|
-
// arithmetic was legible in the screenshot: trigger at 640, popover at 300,
|
|
148
|
-
// and 640 - 340 - 4 = 316.
|
|
149
|
-
//
|
|
150
|
-
// The element is rendered under x-show (display is toggled, it stays in the
|
|
151
|
-
// DOM), so offsetHeight is readable as soon as it is shown. The constant
|
|
152
|
-
// survives only as the fallback for the first call, before the ref exists.
|
|
153
|
-
place() {
|
|
154
|
-
var el = this.$refs.trigger;
|
|
155
|
-
if (!el) return;
|
|
156
|
-
|
|
157
|
-
var r = el.getBoundingClientRect();
|
|
158
|
-
var popover = this.$refs.popover;
|
|
159
|
-
var height = (popover && popover.offsetHeight) || 340;
|
|
160
|
-
var GAP = 4;
|
|
161
|
-
|
|
162
|
-
this.left = r.left;
|
|
163
|
-
this.width = r.width;
|
|
164
|
-
|
|
165
|
-
// THE SIDE IS NOT RE-DECIDED HERE. place() runs on every scroll and
|
|
166
|
-
// resize event, and choosing the side each time makes the popover SNAP
|
|
167
|
-
// between above and below the moment the available space crosses the
|
|
168
|
-
// threshold. Measured on turf-monster at 60px scroll steps: ABOVE, ABOVE,
|
|
169
|
-
// ABOVE, ABOVE, then below — a 254px jump between two steps, mid-scroll,
|
|
170
|
-
// while the reader was looking at it. That is what "the date picker
|
|
171
|
-
// freaks out on scroll" was.
|
|
172
|
-
//
|
|
173
|
-
// chooseSide() runs ONCE per open; this only moves the popover.
|
|
174
|
-
this.top = this.flipped ? r.top - height - GAP : r.bottom + GAP;
|
|
175
|
-
},
|
|
176
|
-
|
|
177
|
-
// Which side to open on, decided once and then left alone. Flips above only
|
|
178
|
-
// when there is not room below AND there is room above — with neither, it
|
|
179
|
-
// stays below, attached to its field rather than pinned to the top of the
|
|
180
|
-
// viewport.
|
|
181
|
-
chooseSide() {
|
|
182
|
-
var el = this.$refs.trigger;
|
|
183
|
-
if (!el) return;
|
|
184
|
-
|
|
185
|
-
var r = el.getBoundingClientRect();
|
|
186
|
-
var popover = this.$refs.popover;
|
|
187
|
-
var height = (popover && popover.offsetHeight) || 340;
|
|
188
|
-
var GAP = 4;
|
|
189
|
-
var below = window.innerHeight - r.bottom;
|
|
190
|
-
|
|
191
|
-
this.flipped = below < height + GAP && r.top > height + GAP;
|
|
85
|
+
// `fields` belongs to the enclosing studioProfileForm. Alpine's scope chain
|
|
86
|
+
// makes it reachable and it is the SAME object, so assigning through it is
|
|
87
|
+
// what raises the save bar. Without this the field would set a value the
|
|
88
|
+
// dirty check never notices.
|
|
89
|
+
publish() {
|
|
90
|
+
if (this.fields) this.fields.birthday = this.isoValue;
|
|
192
91
|
}
|
|
193
92
|
};
|
|
194
93
|
};
|
data/lib/studio/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.54.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
@@ -298,6 +298,7 @@ files:
|
|
|
298
298
|
- app/views/studio/emails/index.html.erb
|
|
299
299
|
- app/views/studio/emails/orphan.html.erb
|
|
300
300
|
- app/views/studio/emails/show.html.erb
|
|
301
|
+
- app/views/studio/fields/_date_of_birth.html.erb
|
|
301
302
|
- app/views/studio/links/confirm.html.erb
|
|
302
303
|
- app/views/studio/local_emails/index.html.erb
|
|
303
304
|
- app/views/studio/mailers/_layered_banner.html.erb
|