Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
338a521
docs: design in-house stats year picker
KucharczykL Aug 3, 2026
7858f8c
docs: align year picker design with shared machinery
KucharczykL Aug 3, 2026
01bdb79
docs: specify shared date-calendar tab behavior
KucharczykL Aug 3, 2026
eea9819
docs: refine year picker accessibility semantics
KucharczykL Aug 3, 2026
1cfad05
docs: plan in-house stats year picker
KucharczykL Aug 3, 2026
badfb45
feat: let date-calendar panels keep focus while tabbing
KucharczykL Aug 3, 2026
9cf246c
feat: render stats year picker as an in-house calendar
KucharczykL Aug 3, 2026
cc538b2
feat: replace Flowbite stats year picker
KucharczykL Aug 3, 2026
ecff752
chore: remove stats datepicker bundle
KucharczykL Aug 3, 2026
2f48576
fix: host year picker inside shared dropdown
KucharczykL Aug 3, 2026
bab09b7
test: cover stats year picker in a browser
KucharczykL Aug 3, 2026
5f09720
fix: preserve date-calendar focus during internal updates
KucharczykL Aug 3, 2026
1b299fc
fix: keep year picker grid inside popup
KucharczykL Aug 3, 2026
1657ab2
docs: plan intrinsic year picker popup sizing
KucharczykL Aug 3, 2026
1f72e63
fix: size year picker popup from calendar content
KucharczykL Aug 3, 2026
bc03d6c
style: format intrinsic popup plan
KucharczykL Aug 3, 2026
a85d615
fix: keep date-calendar open during pointer focus transitions
KucharczykL Aug 3, 2026
070ac00
fix: keep year picker open at navigation boundaries
KucharczykL Aug 3, 2026
917b91e
docs: retain timeless year picker popup contract
KucharczykL Aug 3, 2026
91209a4
docs: consolidate year picker plan
KucharczykL Aug 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ Only a small number of HTML templates remain (platform icon snippets and partial

- **HTMX** (`games/static/js/htmx.min.js`) — partial page updates
- **Alpine.js** (vendored: `alpine.min.js`, `alpine-mask.min.js`) — reactive dropdowns (`GameStatusSelector`, `SessionDeviceSelector`), toast store
- **Flowbite** (vendored: `flowbite.min.js`; `datepicker.umd.js` for the stats YearPicker) — navbar collapse, dropdown toggles
- **Flowbite** (vendored: `flowbite.min.js`) — navbar collapse, dropdown toggles
- **Tailwind CSS** — utility classes, compiled from `common/input.css` → `games/static/base.css`
- All third-party JS is served locally from `games/static/js/` (no CDNs), so pages and browser tests work offline
- **Custom JS** authored in TypeScript under `ts/`, compiled to `games/static/js/dist/` (gitignored, build-only):
Expand Down
122 changes: 101 additions & 21 deletions common/components/primitives.py
Original file line number Diff line number Diff line change
Expand Up @@ -1356,12 +1356,10 @@ def StaticScript(filename: str) -> Node:
return Script(src=static("js/" + filename))


# The <year-picker> custom element wraps the Flowbite-datepicker year grid.
# The builder auto-attaches dist/elements/year-picker.js; the vendored UMD
# bundle (classic script, runs during parse) is merged in via with_media so
# Datepicker is defined by the time the deferred element module executes.
# The <year-picker> custom element renders the stats year grid in TypeScript.
# The builder auto-attaches dist/elements/year-picker.js; its popup is hosted by
# the same date-calendar <drop-down> machinery as the date pickers.
_YearPicker = custom_element_builder("year-picker")
_DATEPICKER_MEDIA = Media(js_external=("datepicker.umd.js",))

# The down-chevron rendered inside the YearPicker button. Trusted static SVG.
_YEAR_PICKER_CHEVRON = Safe(
Expand All @@ -1372,24 +1370,52 @@ def StaticScript(filename: str) -> Node:
)


# Every year cell is a ControlButton with a fixed width so the four columns stay
# aligned regardless of the label. The complete state classes are generated to
# TypeScript because the client clones the template twelve times.
_YEAR_CELL_GEOMETRY_CLASS = "w-14 shrink-0"
YEAR_PICKER_CLASSES: dict[str, str] = {
"default": f"{control_button_class(variant='ghost')} {_YEAR_CELL_GEOMETRY_CLASS}",
"selected": (
f"{control_button_class(color='blue', variant='filled')} "
f"{_YEAR_CELL_GEOMETRY_CLASS}"
),
"adjacent": (
f"{control_button_class(variant='ghost')} "
f"{_YEAR_CELL_GEOMETRY_CLASS} opacity-40"
),
"disabled": (
f"{control_button_class(variant='ghost')} "
f"{_YEAR_CELL_GEOMETRY_CLASS} opacity-40"
),
"adjacent-disabled": (
f"{control_button_class(variant='ghost')} "
f"{_YEAR_CELL_GEOMETRY_CLASS} opacity-40"
),
}


def YearPicker(
year: int | None = None,
available_years: tuple[int, ...] = (),
url_template: str = "",
) -> Node:
"""A Flowbite-datepicker year picker.
"""An in-house four-column stats year picker.

`year` is the selected year, or ``None`` for the all-time view (the empty
state). `available_years` are the years to enable in the popup grid.
`url_template` is a navigation URL containing the literal ``__year__``
placeholder, substituted with the chosen year in JS (keeps this component
decoupled from the project's URL names).

Behavior lives in ``ts/elements/year-picker.ts``; this renders the light
DOM (toggle button + hidden datepicker input). The element module and the
Flowbite UMD bundle are declared as ``media`` on the node, so ``TimetrackerDocument()``
loads both automatically.
Behavior lives in ``ts/elements/year-picker.ts``; this renders the toggle,
date-calendar dropdown shell, accessible popup structure, and a native
ControlButton template for the twelve year cells.
"""
# custom_elements imports this module, so the dropdown builder and its
# shared overlay surface are imported lazily after module initialization.
from common.components.custom_elements import OVERLAY_SURFACE_CLASS, _Dropdown

label = str(year) if year is not None else "Choose a year"
selected = str(year) if year is not None else ""
classes = (
Expand All @@ -1399,18 +1425,28 @@ def YearPicker(
"hover:bg-neutral-tertiary-medium focus:ring-4 focus:ring-brand-medium"
)
years_csv = ",".join(str(y) for y in available_years)
return _YearPicker(
popup_id = "year-picker-popup"
period_id = "year-picker-period"
popup_class = (
"absolute z-20 flex w-auto overflow-x-hidden overflow-y-auto rounded-base "
f"{OVERLAY_SURFACE_CLASS} shadow-sm border border-default-medium"
)
picker = _YearPicker(
[
("selected-year", selected),
("available-years", years_csv),
("url-template", url_template),
("class", "relative inline-block"),
("class", "inline-block"),
]
)[
Button(
[
("type", "button"),
("data-toggle", ""),
("data-year-picker-toggle", ""),
("aria-controls", popup_id),
("aria-expanded", "false"),
("aria-haspopup", "dialog"),
(
"class",
(
Expand All @@ -1420,15 +1456,59 @@ def YearPicker(
),
]
)[label, _YEAR_PICKER_CHEVRON],
Input(
id_="year-picker-input",
class_="absolute opacity-0 pointer-events-none",
style=(
"width: 1px; height: 1px; padding: 0; margin: -1px; "
"overflow: hidden; clip: rect(0,0,0,0); border: 0;"
),
),
].with_media(_DATEPICKER_MEDIA)
Div(
[
("data-menu", ""),
("data-year-picker-popup", ""),
("id", popup_id),
("hidden", ""),
("role", "group"),
("aria-labelledby", period_id),
("class", popup_class),
]
)[
Div(data_year_picker_body="", class_="p-2")[
Div(class_="flex items-center justify-between gap-2")[
ControlButton(
[("data-year-picker-prev", "")],
variant="ghost",
aria_label="Previous decade",
class_=_YEAR_CELL_GEOMETRY_CLASS,
)["‹"],
Span(
[
("id", period_id),
("data-year-picker-period", ""),
("class", "text-type-body font-medium text-heading"),
]
),
ControlButton(
[("data-year-picker-next", "")],
variant="ghost",
aria_label="Next decade",
class_=_YEAR_CELL_GEOMETRY_CLASS,
)["›"],
],
Div(
class_="grid grid-cols-4 gap-y-0.5 mt-1 w-56",
data_year_picker_grid="",
),
Template(data_year_picker_template="year")[
ControlButton(
[("data-year", "")],
variant="ghost",
class_=_YEAR_CELL_GEOMETRY_CLASS,
)
],
]
],
]
return _Dropdown(
class_="relative inline-block",
placement="bottom-end",
submenu="false",
behavior="date-calendar",
)[picker]


# Form-field rendering. The element classes (label/error/checkbox-row + the
Expand Down
46 changes: 46 additions & 0 deletions docs/superpowers/plans/year-picker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Stats YearPicker

The stats YearPicker is an in-house four-column decade selector. It preserves
the public Python API:

```python
YearPicker(year, available_years, url_template)
```

## Architecture

- The component is hosted by `<drop-down behavior="date-calendar">`.
- Shared dropdown/calendar machinery owns popup positioning, ARIA expanded
state, viewport clamping, outside-click dismissal, Escape, focus-leave, and
single-open coordination.
- `year-picker.ts` owns only decade state, twelve-cell rendering, availability,
decade navigation, and immediate navigation after selecting an enabled year.
- The legacy Flowbite datepicker bundle and hidden input are not part of the
implementation.

## User-visible behavior

- The grid has four columns and twelve cells: one adjacent year on either side
of the active decade.
- Selectable years are bounded by 1999 and the browser's current year, then
filtered by `available_years`.
- Enabled year selection navigates immediately through `url_template`; an
empty template never navigates.
- Previous/next decade controls stay within those bounds. Reaching a boundary
can disable the focused arrow without closing the popup.
- Tab remains in the popup while focus moves between its controls and closes
only after focus genuinely exits.

## Layout contract

- Cells use `w-14 shrink-0`; the four-column grid uses `w-56`.
- The popup surface is intrinsic (`flex w-auto`) and its inner body owns
`p-2`; the surface has no independently calculated width.
- The shared anchored positioner measures, flips, and clamps the popup.

## Verification

- Rendering and unit tests protect the component API, year bounds, grid
geometry, and shared-menu focus behavior.
- Browser coverage protects navigation, repeated boundary navigation,
font-size scaling, and narrow-viewport containment.
Loading
Loading