@net-advantage/jo-ui-year-bar
v2.1.0
Published
Publishable year bar for a journey timeline.
Readme
@net-advantage/jo-ui-year-bar
Publishable year bar for a journey timeline.
The bar follows a strict MVVM split. The model owns the window, the pills, and the navigation rules. The view-model owns disabled state, keyboard movement, and scrolling the selected year into view. The view renders that view-model with jo-ui Button, SegmentedControl, and Dropdown.
MVVM
flowchart LR
Props["YearBarProps"] --> ViewModel["useYearBarViewModel"]
Model["jo-ui-year-bar.model.ts<br/>window, pills, keys, disabled ends"] --> ViewModel
ViewModel --> View["YearBar.tsx"]
View -->|"onSelectYear, onSpanChange"| ViewModel
ViewModel -->|"onSelectYear, onSpanChange"| Host["Host application"]| Layer | File | Responsibility |
| --- | --- | --- |
| Contract | jo-ui-year-bar.types.ts | Range, span, selection, and the view-model |
| Model | jo-ui-year-bar.model.ts | Build the window and pills, decide the next year, mark today when the selection is empty, copy root attributes, and normalise the sticky class and chrome labels |
| View-model | jo-ui-year-bar.view-model.ts | Framework-free year bar observable; guard clicks and move by keyboard |
| View | jo-ui-year-bar.use-view-model.ts | useYearBarViewModel reads that observable and owns scroll and pending focus |
| View | YearBar.tsx | Render the bar from the view-model |
Models
flowchart TD
Range["YearRange"] --> Bounds["range window"]
Selected["selectedYear or today"] --> Anchor["anchor"]
Bounds --> Anchor
Span["5Y, 10Y, or All"] --> Window["visible window"]
Anchor --> Window
Window --> Pills["pills minus omitted years"]
Moments["momentYears"] --> Pills
Pills --> Move["previous, next, today, keys"]
Pills --> Mark["today mark when empty"]yearSpanLengthis{ 5Y: 5, 10Y: 10 }.yearsAfterAnchoris1.yearBarWindow({ range, selectedYear, span, todayYear })returns{ from, to }, the inclusive years the bar lays out. The bar calls this function. 5Y and 10Y run from the anchor minus the span through the year after the anchor. The anchor is the selected year, or today clamped into the range. The window may pass the journey dates.- All runs from
min(range.startYear, todayYear + 1)throughtodayYear + 1. Omitted years stay insideyearBarWindow; they are removed only from the pills. - An ongoing range extends through today when
endYearis earlier. - Omitted years are not pills. A pill
hasMomentswhen its year is inmomentYears. Empty years use--jo-color-year-bar-muted. Years with moments use--jo-color-year-bar-text. - Block padding is
6pxbelow 1500px and8pxfrom 1500px, from--jo-year-bar-padding-blockand--jo-year-bar-padding-block-wide. That is a 38px bar, then a 42px bar. - A year pill's keyboard focus ring is
2pxsolid--jo-color-focuswithoutline-offset: 2px. The years strip keeps4pxof block padding for that ring and cancels it withmargin-block: -4px, sooverflow-x: autodoes not clip the ring and the bar stays 38px, then 42px. - Previous and next move to the neighbouring pill. When the selection is not a pill they move to the last or first pill. They are disabled on the first or last pill, and when there are no pills.
- Today selects today clamped into the range, then the nearest pill. It is disabled only when there are no pills. A tie keeps the earlier year.
- A span change does not change the selection. With
clearable, clicking the selected pill callsonSelectYear(null). - With
highlightTodayWhenEmptyandselectedYearnull, today's pill uses the active fill plus an underline andaria-current="date".aria-pressedstays false, so the mark is not the selection. Clicking it selects that year. If today is omitted or outside the window, no pill is marked. A real selection still usesaria-current="true"and is not given the today mark. - Arrow keys, Home, and End move the roving tab stop. The selected pill scrolls into view. With the today mark and no selection, that marked pill scrolls into view. The roving tab stop stays on the first pill when nothing is selected.
- A missing
sizeisxs.sizestaysxsonly. That face is 38px below 1500px, 42px from 1500px, and the 44px compact row when the slot is at or under--jo-year-bar-compact-max. No larger size ships until a design defines its metrics. A missingariaLabelisYear. A missingclearablestays off. A missinghighlightTodayWhenEmptystays off. A missingstickystays on. sticky={false}leaves the bar in normal flow, for a bar that sits at the bottom of a page. The default pins it withposition: sticky,top: var(--jo-year-bar-sticky-top, 0px), andz-index: var(--jo-year-bar-z-index, 2). The class isjo-ui-year-bar--sticky. Both tokens are0pxand2, the same in light and dark. A host can override them. The fallbacks are those same values when the theme tokens are absent. The bar sticks inside its scroll parent, so that parent has to extend past the bar. A wrapper that is only as tall as the bar scrolls away with it.labelsreplaces the chrome strings.previousdefaults to "Previous year",nextto "Next year",todayto "Today",years(the years group) to "Years", andspan(the span control) to "Year span". A blank or whitespace value keeps that default. The arrow glyphs and the span option text (5Y, 10Y, All years) stay fixed.data-*andaria-*props are copied onto the root section.classNameis appended tojo-ui-year-bar. The accessible name staysariaLabel. There is nostyleprop.- The root width is
autowithbox-sizing: border-box, so it fills its slot and a horizontal margin or padding on the root stays inside that slot. Put the margin on a host class passed throughclassName. Wrap the bar when the slot itself should be inset. - Previous and next are secondary
xsicon-only Buttons. Today is a secondaryxsButton. Above the compact slot, 5Y / 10Y / All is anxsSegmentedControl. Unselected span labels use--jo-color-year-bar-span-text. All reads as "All years". - Colour, type size, padding, gaps, and radius come from the year-bar theme tokens. Navy and white are
--jo-color-year-bar-activeand--jo-color-year-bar-active-text. The font is--jo-font-familythrough--jo-ui-control-font-family. - The shipped stylesheet is scoped to
.jo-ui-year-bar. It does not emit Tailwind utility selectors such as.flex,.hidden, or.block. - A slot at or under
--jo-year-bar-compact-max(768px) uses the compact layout. The container query is on the bar, so the slot decides, not the viewport. The 960px desktop bar stays the single-row layout. - The compact bar stays one row. Its layout height is
--jo-year-bar-hit-min(44px), with the track padding removed so nothing stacks below the row. Previous and next are hidden. Today stays, at thexsface (--jo-ui-button-xs-height), with a::beforehit area of--jo-year-bar-hit-min. The span group is a jo-ui Dropdown. Its accessible name comes fromlabels.span, which defaults to "Year span". The options are 5Y, 10Y, and All years. The select box is--jo-year-bar-hit-mintall. - The years strip fills the remaining width. It scrolls on the horizontal axis (
overflow-x: auto,overflow-y: hidden, scroll snapx proximity). The scrollbar is hidden (scrollbar-width: none) and the strip stays scrollable. The desktop ring padding is cleared here (padding-block: 0,margin-block: 0) because the row is already--jo-year-bar-hit-mintall. The focus ring stays a2pxoutline in--jo-color-focus, drawn2pxoutside the painted face and inside--jo-year-bar-pill-hit-border, so the 44px row does not clip it. Pills use the Sarah mock's mobile strip: min height--jo-year-bar-hit-min(44px), min width--jo-year-bar-pill-min-width-compact(58px), type--jo-year-bar-pill-font-size-compact(12.5px), a--jo-year-bar-pill-hit-border(6px) transparent border withbackground-clip: padding-box, and radius--jo-year-bar-pill-radius-compact(999px). The year gap is--jo-year-bar-year-gap-compact(0px). The strip padding is--jo-year-bar-year-padding-inline-compact(8px), with an--jo-year-bar-year-fade(18px) edge mask. The selected year is centred in the strip. Arrow keys, Home, and End still move the roving tab stop.
Structure
- src: runtime component, model, view-model, types, and companion tests.
- stories: Storybook stories for 5Y, 10Y, All, clearable, today marked when nothing is selected, host data and aria attributes, an unpinned bar, host labels, no pills, omitted years, dark, mobile (375px), and tablet (768px), each of the last two in light and dark. The today mark has light, dark, and 375px stories. Focus stories check the selected year's ring at desktop and at 375px.
Publish footprint
This package publishes only:
- dist
- README.md
Stories and tests are excluded from the published artifact by design.
The ESM bundle imports react and react/jsx-runtime from the host. It does not bundle React's JSX runtime or call require.
